From a9de890cd863c83b1ac4f675b3445e0acda356ea Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 16:04:17 +0200 Subject: [PATCH 01/53] Add chat_session_state table migration --- .../73_add_chat_session_state_table.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 surfsense_backend/alembic/versions/73_add_chat_session_state_table.py diff --git a/surfsense_backend/alembic/versions/73_add_chat_session_state_table.py b/surfsense_backend/alembic/versions/73_add_chat_session_state_table.py new file mode 100644 index 000000000..12e99f306 --- /dev/null +++ b/surfsense_backend/alembic/versions/73_add_chat_session_state_table.py @@ -0,0 +1,75 @@ +"""Add chat_session_state table for live collaboration + +Revision ID: 73 +Revises: 72 + +Creates chat_session_state table to track AI responding state per thread. +Enables real-time sync via Electric SQL for shared chat collaboration. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "73" +down_revision: str | None = "72" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create chat_session_state table with Electric SQL replication.""" + op.execute( + """ + CREATE TABLE IF NOT EXISTS chat_session_state ( + id SERIAL PRIMARY KEY, + thread_id INTEGER NOT NULL REFERENCES new_chat_threads(id) ON DELETE CASCADE, + ai_responding_to_user_id UUID REFERENCES "user"(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (thread_id) + ) + """ + ) + + op.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_session_state_thread_id ON chat_session_state(thread_id)" + ) + + op.execute("ALTER TABLE chat_session_state REPLICA IDENTITY FULL;") + + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'electric_publication_default' + AND tablename = 'chat_session_state' + ) THEN + ALTER PUBLICATION electric_publication_default ADD TABLE chat_session_state; + END IF; + END + $$; + """ + ) + + +def downgrade() -> None: + """Drop chat_session_state table and remove from Electric SQL replication.""" + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'electric_publication_default' + AND tablename = 'chat_session_state' + ) THEN + ALTER PUBLICATION electric_publication_default DROP TABLE chat_session_state; + END IF; + END + $$; + """ + ) + + op.execute("DROP TABLE IF EXISTS chat_session_state;") From 7d35419b882da4c206af249637029ffea707b031 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 16:17:54 +0200 Subject: [PATCH 02/53] Add ChatSessionState model --- surfsense_backend/app/db.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 38e27ecf2..35b512c5e 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -472,6 +472,38 @@ class ChatCommentMention(BaseModel, TimestampMixin): mentioned_user = relationship("User") +class ChatSessionState(BaseModel): + """ + Tracks real-time session state for shared chat collaboration. + One record per thread, synced via Electric SQL. + """ + + __tablename__ = "chat_session_state" + + thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + ai_responding_to_user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + updated_at = Column( + TIMESTAMP(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + thread = relationship("NewChatThread") + ai_responding_to_user = relationship("User") + + class Document(BaseModel, TimestampMixin): __tablename__ = "documents" From 55a07c064e8e0d56f99cb0d9c2094d591235169f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 16:24:37 +0200 Subject: [PATCH 03/53] Add chat session state schemas --- .../app/schemas/chat_session_state.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 surfsense_backend/app/schemas/chat_session_state.py diff --git a/surfsense_backend/app/schemas/chat_session_state.py b/surfsense_backend/app/schemas/chat_session_state.py new file mode 100644 index 000000000..6eca0e26f --- /dev/null +++ b/surfsense_backend/app/schemas/chat_session_state.py @@ -0,0 +1,29 @@ +""" +Pydantic schemas for chat session state (live collaboration). +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class RespondingUser(BaseModel): + """The user that the AI is currently responding to.""" + + id: UUID + display_name: str | None = None + email: str + + model_config = ConfigDict(from_attributes=True) + + +class ChatSessionStateResponse(BaseModel): + """Current session state for a chat thread.""" + + id: int + thread_id: int + responding_to: RespondingUser | None = None + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) From d7b0b90a01c40d941440e84078c3944d95e61326 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 16:31:40 +0200 Subject: [PATCH 04/53] Add chat session state service --- .../services/chat_session_state_service.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 surfsense_backend/app/services/chat_session_state_service.py diff --git a/surfsense_backend/app/services/chat_session_state_service.py b/surfsense_backend/app/services/chat_session_state_service.py new file mode 100644 index 000000000..d82fff3a7 --- /dev/null +++ b/surfsense_backend/app/services/chat_session_state_service.py @@ -0,0 +1,65 @@ +""" +Service layer for chat session state (live collaboration). +""" + +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db import ChatSessionState + + +async def get_session_state( + session: AsyncSession, + thread_id: int, +) -> ChatSessionState | None: + """Get the current session state for a thread.""" + result = await session.execute( + select(ChatSessionState) + .options(selectinload(ChatSessionState.ai_responding_to_user)) + .filter(ChatSessionState.thread_id == thread_id) + ) + return result.scalar_one_or_none() + + +async def set_ai_responding( + session: AsyncSession, + thread_id: int, + user_id: UUID, +) -> ChatSessionState: + """Mark AI as responding to a specific user. Uses upsert for atomicity.""" + now = datetime.now(UTC) + upsert_query = insert(ChatSessionState).values( + thread_id=thread_id, + ai_responding_to_user_id=user_id, + updated_at=now, + ) + upsert_query = upsert_query.on_conflict_do_update( + index_elements=["thread_id"], + set_={ + "ai_responding_to_user_id": user_id, + "updated_at": now, + }, + ) + await session.execute(upsert_query) + await session.commit() + + return await get_session_state(session, thread_id) + + +async def clear_ai_responding( + session: AsyncSession, + thread_id: int, +) -> ChatSessionState | None: + """Clear AI responding state when response is complete.""" + state = await get_session_state(session, thread_id) + if state: + state.ai_responding_to_user_id = None + state.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(state) + return state From dc628198cec68f60b9878b5bd0eb26e6699d0848 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 16:40:38 +0200 Subject: [PATCH 05/53] Integrate session state into chat streaming --- surfsense_backend/app/routes/new_chat_routes.py | 1 + .../app/tasks/chat/stream_new_chat.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 8fddc55c4..1ef3d0822 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -990,6 +990,7 @@ async def handle_new_chat( search_space_id=request.search_space_id, chat_id=request.chat_id, session=session, + user_id=user.id, llm_config_id=llm_config_id, attachments=request.attachments, mentioned_document_ids=request.mentioned_document_ids, diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 85a524108..984cecc6d 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -11,6 +11,7 @@ Supports loading LLM configurations from: import json from collections.abc import AsyncGenerator +from uuid import UUID from langchain_core.messages import HumanMessage from sqlalchemy.ext.asyncio import AsyncSession @@ -27,6 +28,10 @@ from app.agents.new_chat.llm_config import ( ) from app.db import Document, SurfsenseDocsDocument from app.schemas.new_chat import ChatAttachment +from app.services.chat_session_state_service import ( + clear_ai_responding, + set_ai_responding, +) from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService @@ -149,6 +154,7 @@ async def stream_new_chat( search_space_id: int, chat_id: int, session: AsyncSession, + user_id: UUID, llm_config_id: int = -1, attachments: list[ChatAttachment] | None = None, mentioned_document_ids: list[int] | None = None, @@ -166,8 +172,8 @@ async def stream_new_chat( search_space_id: The search space ID chat_id: The chat ID (used as LangGraph thread_id for memory) session: The database session + user_id: The ID of the user sending the message llm_config_id: The LLM configuration ID (default: -1 for first global config) - messages: Optional chat history from frontend (list of ChatMessage) attachments: Optional attachments with extracted content mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat @@ -181,6 +187,8 @@ async def stream_new_chat( current_text_id: str | None = None try: + # Mark AI as responding to this user for live collaboration + await set_ai_responding(session, chat_id, user_id) # Load LLM config - supports both YAML (negative IDs) and database (positive IDs) agent_config: AgentConfig | None = None @@ -1144,3 +1152,7 @@ async def stream_new_chat( yield streaming_service.format_finish_step() yield streaming_service.format_finish() yield streaming_service.format_done() + + finally: + # Clear AI responding state for live collaboration + await clear_ai_responding(session, chat_id) From 6a31e79edeb2da2fa1770fe666b9c84e379158e1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 16:52:18 +0200 Subject: [PATCH 06/53] Add chat session state types for live collaboration --- .../types/chat-session-state.types.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 surfsense_web/contracts/types/chat-session-state.types.ts diff --git a/surfsense_web/contracts/types/chat-session-state.types.ts b/surfsense_web/contracts/types/chat-session-state.types.ts new file mode 100644 index 000000000..cf73859e6 --- /dev/null +++ b/surfsense_web/contracts/types/chat-session-state.types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * Chat session state for live collaboration. + * Tracks which user the AI is currently responding to. + */ +export const chatSessionState = z.object({ + id: z.number(), + thread_id: z.number(), + ai_responding_to_user_id: z.string().uuid().nullable(), + updated_at: z.string(), +}); + +/** + * User currently being responded to by the AI. + */ +export const respondingUser = z.object({ + id: z.string().uuid(), + display_name: z.string().nullable(), + email: z.string(), +}); + +export type ChatSessionState = z.infer; +export type RespondingUser = z.infer; From 69f2460d18a748e569c880df5d1188fbf593a80c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 18:26:58 +0200 Subject: [PATCH 07/53] Refactor chat session state hook to use useShape --- surfsense_web/hooks/use-chat-session-state.ts | 28 +++++++++++++++++++ surfsense_web/lib/electric/client.ts | 1 - 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/hooks/use-chat-session-state.ts diff --git a/surfsense_web/hooks/use-chat-session-state.ts b/surfsense_web/hooks/use-chat-session-state.ts new file mode 100644 index 000000000..0272464df --- /dev/null +++ b/surfsense_web/hooks/use-chat-session-state.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useShape } from "@electric-sql/react"; +import type { ChatSessionState } from "@/contracts/types/chat-session-state.types"; + +const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; + +export function useChatSessionState(threadId: number | null) { + const { data, isLoading, isError, error } = useShape({ + url: `${ELECTRIC_URL}/v1/shape`, + params: { + table: "chat_session_state", + where: `thread_id = ${threadId}`, + }, + // Skip fetching if no threadId + ...(threadId ? {} : { url: undefined as unknown as string }), + }); + + const sessionState = data?.[0] ?? null; + + return { + sessionState, + isAiResponding: !!sessionState?.ai_responding_to_user_id, + respondingToUserId: sessionState?.ai_responding_to_user_id ?? null, + loading: isLoading, + error: isError ? error : null, + }; +} diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 514185d23..d900ddb0a 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -228,7 +228,6 @@ 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, From b56c70c401c4e395617384d2b3b4fc13c91c0cec Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 18:33:29 +0200 Subject: [PATCH 08/53] Add ChatSessionStatus component with animation --- .../assistant-ui/chat-session-status.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 surfsense_web/components/assistant-ui/chat-session-status.tsx diff --git a/surfsense_web/components/assistant-ui/chat-session-status.tsx b/surfsense_web/components/assistant-ui/chat-session-status.tsx new file mode 100644 index 000000000..aef4aaeb0 --- /dev/null +++ b/surfsense_web/components/assistant-ui/chat-session-status.tsx @@ -0,0 +1,49 @@ +"use client"; + +import type { FC } from "react"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ChatSessionStatusProps { + isAiResponding: boolean; + respondingToUserId: string | null; + currentUserId: string | null; + members: Array<{ + user_id: string; + user_display_name: string | null; + user_email: string; + }>; + className?: string; +} + +export const ChatSessionStatus: FC = ({ + isAiResponding, + respondingToUserId, + currentUserId, + members, + className, +}) => { + if (!isAiResponding || !respondingToUserId) { + return null; + } + + if (respondingToUserId === currentUserId) { + return null; + } + + const respondingUser = members.find((m) => m.user_id === respondingToUserId); + const displayName = respondingUser?.user_display_name || respondingUser?.user_email || "another user"; + + return ( +
+ + Currently responding to {displayName} +
+ ); +}; From 22ead877fa80fbcf13883fa27379eb0e9e17a3f7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 18:39:50 +0200 Subject: [PATCH 09/53] Integrate ChatSessionStatus and blocking logic into Composer --- .../assistant-ui/chat-session-status.tsx | 4 +-- .../components/assistant-ui/thread.tsx | 30 ++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/chat-session-status.tsx b/surfsense_web/components/assistant-ui/chat-session-status.tsx index aef4aaeb0..62f7c33ce 100644 --- a/surfsense_web/components/assistant-ui/chat-session-status.tsx +++ b/surfsense_web/components/assistant-ui/chat-session-status.tsx @@ -10,8 +10,8 @@ interface ChatSessionStatusProps { currentUserId: string | null; members: Array<{ user_id: string; - user_display_name: string | null; - user_email: string; + user_display_name?: string | null; + user_email?: string | null; }>; className?: string; } diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index eaf30fc96..1d83b5a60 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -31,6 +31,7 @@ import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, } from "@/atoms/chat/mentioned-documents.atom"; +import { membersAtom } from "@/atoms/members/members-query.atoms"; import { globalNewLLMConfigsAtom, llmPreferencesAtom, @@ -39,6 +40,7 @@ import { import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { AssistantMessage } from "@/components/assistant-ui/assistant-message"; import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment"; +import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status"; import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { InlineMentionEditor, @@ -59,6 +61,7 @@ import { import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import type { Document } from "@/contracts/types/document.types"; +import { useChatSessionState } from "@/hooks/use-chat-session-state"; import { cn } from "@/lib/utils"; interface ThreadProps { @@ -215,7 +218,7 @@ const Composer: FC = () => { const editorRef = useRef(null); const editorContainerRef = useRef(null); const documentPickerRef = useRef(null); - const { search_space_id } = useParams(); + const { search_space_id, chat_id } = useParams(); const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); @@ -223,6 +226,18 @@ const Composer: FC = () => { const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + // Live collaboration: track AI responding state + const { data: currentUser } = useAtomValue(currentUserAtom); + const { data: members } = useAtomValue(membersAtom); + const threadId = useMemo(() => { + if (Array.isArray(chat_id) && chat_id.length > 0) { + return Number.parseInt(chat_id[0], 10) || null; + } + return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null; + }, [chat_id]); + const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); + const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; + // Auto-focus editor on new chat page after mount useEffect(() => { if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) { @@ -298,9 +313,9 @@ const Composer: FC = () => { [showDocumentPopover] ); - // Submit message (blocked during streaming or when document picker is open) + // Submit message (blocked during streaming, document picker open, or AI responding to another user) const handleSubmit = useCallback(() => { - if (isThreadRunning) { + if (isThreadRunning || isBlockedByOtherUser) { return; } if (!showDocumentPopover) { @@ -315,6 +330,7 @@ const Composer: FC = () => { }, [ showDocumentPopover, isThreadRunning, + isBlockedByOtherUser, composerRuntime, setMentionedDocuments, setMentionedDocumentIds, @@ -374,7 +390,13 @@ const Composer: FC = () => { ); return ( - + + {/* Inline editor with @mention support */} From 17f8c993df801043f96c8d2116b559a7d9249dcc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 20 Jan 2026 19:48:28 +0200 Subject: [PATCH 10/53] Simplify chat session state hook and disable send button when blocked --- .../components/assistant-ui/thread.tsx | 29 ++++++++++++------- surfsense_web/hooks/use-chat-session-state.ts | 6 ++-- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 1d83b5a60..d65372c24 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -226,7 +226,7 @@ const Composer: FC = () => { const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); - // Live collaboration: track AI responding state + // Live collaboration state const { data: currentUser } = useAtomValue(currentUserAtom); const { data: members } = useAtomValue(membersAtom); const threadId = useMemo(() => { @@ -439,13 +439,17 @@ const Composer: FC = () => { />, document.body )} - + ); }; -const ComposerAction: FC = () => { +interface ComposerActionProps { + isBlockedByOtherUser?: boolean; +} + +const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => { // Check if any attachments are still being processed (running AND progress < 100) // When progress is 100, processing is done but waiting for send() const hasProcessingAttachments = useAssistantState(({ composer }) => @@ -480,7 +484,8 @@ const ComposerAction: FC = () => { return userConfigs?.some((c) => c.id === agentLlmId) ?? false; }, [preferences, globalConfigs, userConfigs]); - const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured; + const isSendDisabled = + hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; return (
@@ -509,13 +514,15 @@ const ComposerAction: FC = () => { ({ url: `${ELECTRIC_URL}/v1/shape`, @@ -12,8 +16,6 @@ export function useChatSessionState(threadId: number | null) { table: "chat_session_state", where: `thread_id = ${threadId}`, }, - // Skip fetching if no threadId - ...(threadId ? {} : { url: undefined as unknown as string }), }); const sessionState = data?.[0] ?? null; From 01307f36dddd9c0693e8afe2cae6aeb8bc86f7bc Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:14:14 +0530 Subject: [PATCH 11/53] refactor: simplify state management in ChatShareButton and ModelSelector --- .../components/new-chat/chat-share-button.tsx | 17 +---------------- .../components/new-chat/model-selector.tsx | 15 --------------- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 05a8a7306..fcace2572 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { Loader2, User, Users } from "lucide-react"; +import { User, Users } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; @@ -45,7 +45,6 @@ const visibilityOptions: { export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); // Use Jotai atom for visibility (single source of truth) const currentThreadState = useAtomValue(currentThreadAtom); @@ -62,7 +61,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS return; } - setIsUpdating(true); // Update Jotai atom immediately for instant UI feedback setThreadVisibility(newVisibility); @@ -84,8 +82,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS // Revert Jotai state on error setThreadVisibility(thread.visibility ?? "PRIVATE"); toast.error("Failed to update sharing settings"); - } finally { - setIsUpdating(false); } }, [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility] @@ -128,16 +124,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS onCloseAutoFocus={(e) => e.preventDefault()} >
- {/* Updating overlay */} - {isUpdating && ( -
-
- - Updating -
-
- )} - {visibilityOptions.map((option) => { const isSelected = currentVisibility === option.value; const Icon = option.icon; @@ -147,7 +133,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS type="button" key={option.value} onClick={() => handleVisibilityChange(option.value)} - disabled={isUpdating} className={cn( "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all", "hover:bg-accent/50 cursor-pointer", diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index fba5e8cb1..af9378e34 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -72,7 +72,6 @@ interface ModelSelectorProps { export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [isSwitching, setIsSwitching] = useState(false); // Fetch configs const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom); @@ -137,7 +136,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp return; } - setIsSwitching(true); try { await updatePreferences({ search_space_id: Number(searchSpaceId), @@ -150,8 +148,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp } catch (error) { console.error("Failed to switch model:", error); toast.error("Failed to switch model"); - } finally { - setIsSwitching(false); } }, [currentConfig, searchSpaceId, updatePreferences] @@ -216,23 +212,12 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp shouldFilter={false} className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2" > - {/* Switching overlay */} - {isSwitching && ( -
-
- - Switching model... -
-
- )} -
From 4d87c38b5f2d0c16dc62980a48014c47189666fb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:12:24 +0530 Subject: [PATCH 12/53] impr: improve tabbed navigation for active and archived chats in sidebars --- .../ui/sidebar/AllPrivateChatsSidebar.tsx | 55 ++++++++++--------- .../ui/sidebar/AllSharedChatsSidebar.tsx | 55 ++++++++++--------- 2 files changed, 58 insertions(+), 52 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index f5c64cc67..fb1a6ed0d 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -28,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { @@ -277,32 +278,34 @@ export function AllPrivateChatsSidebar({
{!isSearchMode && ( -
- - -
+ setShowArchived(value === "archived")} + className="shrink-0 mx-4" + > + + + + + Active + {activeCount} + + + + + + Archived + {archivedCount} + + + + )}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index f50cb028a..f400e6fc8 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -28,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { @@ -277,32 +278,34 @@ export function AllSharedChatsSidebar({
{!isSearchMode && ( -
- - -
+ setShowArchived(value === "archived")} + className="shrink-0 mx-4" + > + + + + + Active + {activeCount} + + + + + + Archived + {archivedCount} + + + + )}
From 4653eb5f646a4ac9209b8b6c8b22f83f75d600e0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:25:38 +0530 Subject: [PATCH 13/53] refactor: update role management UI components for improved interaction - Renamed props in RolesTab for clarity. - Replaced label elements with button elements in CreateRoleDialog for better accessibility and interaction. - Enhanced toggle functionality for category and permission selection. --- .../dashboard/[search_space_id]/team/page.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 6701342de..132906d9f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -712,9 +712,9 @@ function MembersTab({ function RolesTab({ roles, - groupedPermissions, + groupedPermissions: _groupedPermissions, loading, - onUpdateRole, + onUpdateRole: _onUpdateRole, onDeleteRole, canUpdate, canDelete, @@ -1500,7 +1500,11 @@ function CreateRoleDialog({ return (
-
From 8eec94843464e56d49750f086e46c9bb46dae018 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:13:30 +0530 Subject: [PATCH 14/53] feat: enhance team management UI with avatar initials and role permissions display - Added a helper function to generate avatar initials for team members without an avatar. - Improved the MembersTab component by displaying user avatars or initials. - Introduced a new RolePermissionsDisplay component to present role permissions in a categorized manner. - Updated table headers in MembersTab for better clarity and added icons for visual enhancement. --- .../dashboard/[search_space_id]/team/page.tsx | 261 ++++++++++++++---- 1 file changed, 210 insertions(+), 51 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 132906d9f..a0bc6be03 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -3,29 +3,38 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { + Bot, Calendar, Check, Clock, Copy, Crown, Edit2, + FileText, Hash, Link2, LinkIcon, Loader2, + Logs, + type LucideIcon, + MessageCircle, + MessageSquare, + Mic, MoreHorizontal, + Plug, Plus, RefreshCw, Search, + Settings, Shield, ShieldCheck, Trash2, - User, UserMinus, UserPlus, Users, } from "lucide-react"; import { motion } from "motion/react"; +import Image from "next/image"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -512,6 +521,25 @@ export default function TeamManagementPage() { // ============ Members Tab ============ +// Helper function to get avatar initials +function getAvatarInitials(member: Membership): string { + // Try display name first + if (member.user_display_name) { + const parts = member.user_display_name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return member.user_display_name.slice(0, 2).toUpperCase(); + } + // Try email + if (member.user_email) { + const emailName = member.user_email.split("@")[0]; + return emailName.slice(0, 2).toUpperCase(); + } + // Fallback + return "U"; +} + function MembersTab({ members, roles, @@ -560,7 +588,7 @@ function MembersTab({
setSearchQuery(e.target.value)} className="pl-9" @@ -573,10 +601,30 @@ function MembersTab({ - Member - Role - Joined - Actions + +
+ + Member +
+
+ +
+ + Role +
+
+ +
+ + Joined +
+
+ +
+ + Actions +
+
@@ -601,19 +649,36 @@ function MembersTab({
-
- -
+ {member.user_avatar_url ? ( + + ) : ( +
+ + {getAvatarInitials(member)} + +
+ )} {member.is_owner && ( -
+
)}

- {member.user_email || "Unknown"} + {member.user_display_name || member.user_email || "Unknown"}

+ {member.user_display_name && member.user_email && ( +

+ {member.user_email} +

+ )} {member.is_owner && ( No role {roles.map((role) => ( -
- - {role.name} -
+ {role.name}
))} ) : ( - - - {member.role?.name || "No role"} - + + {member.role?.name || "No role"} + )} -
- + {new Date(member.joined_at).toLocaleDateString()} -
+
{canRemove && !member.is_owner && ( @@ -708,6 +768,130 @@ function MembersTab({ ); } +// ============ Role Permissions Display ============ + +const CATEGORY_CONFIG: Record = { + documents: { label: "Documents", icon: FileText, order: 1 }, + chats: { label: "Chats", icon: MessageSquare, order: 2 }, + comments: { label: "Comments", icon: MessageCircle, order: 3 }, + llm_configs: { label: "LLM Configs", icon: Bot, order: 4 }, + podcasts: { label: "Podcasts", icon: Mic, order: 5 }, + connectors: { label: "Connectors", icon: Plug, order: 6 }, + logs: { label: "Logs", icon: Logs, order: 7 }, + members: { label: "Members", icon: Users, order: 8 }, + roles: { label: "Roles", icon: Shield, order: 9 }, + settings: { label: "Settings", icon: Settings, order: 10 }, +}; + +const ACTION_LABELS: Record = { + create: "Create", + read: "Read", + update: "Update", + delete: "Delete", + invite: "Invite", + view: "View", + remove: "Remove", + manage_roles: "Manage Roles", +}; + +function RolePermissionsDisplay({ permissions }: { permissions: string[] }) { + if (permissions.includes("*")) { + return ( +
+
+ +
+
+

Full Access

+

All permissions granted

+
+
+ ); + } + + // Group permissions by category + const grouped: Record = {}; + for (const perm of permissions) { + const [category, action] = perm.split(":"); + if (!grouped[category]) grouped[category] = []; + grouped[category].push(action); + } + + // Sort categories by predefined order + const sortedCategories = Object.keys(grouped).sort((a, b) => { + const orderA = CATEGORY_CONFIG[a]?.order ?? 99; + const orderB = CATEGORY_CONFIG[b]?.order ?? 99; + return orderA - orderB; + }); + + const categoryCount = sortedCategories.length; + + return ( + + + + + + + + + Role Permissions + + + {permissions.length} permissions across {categoryCount} categories + + + +
+ {sortedCategories.map((category) => { + const actions = grouped[category]; + const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText }; + const IconComponent = config.icon; + return ( +
+
+ + {config.label} +
+
+ {actions.map((action) => ( + + {ACTION_LABELS[action] || action.replace(/_/g, " ")} + + ))} +
+
+ ); + })} +
+
+
+
+ ); +} + // ============ Roles Tab ============ function RolesTab({ @@ -852,32 +1036,7 @@ function RolesTab({ )} -
- -
- {role.permissions.includes("*") ? ( - - Full Access - - ) : ( - role.permissions.slice(0, 5).map((perm) => ( - - {perm.replace(":", " ")} - - )) - )} - {!role.permissions.includes("*") && role.permissions.length > 5 && ( - - +{role.permissions.length - 5} more - - )} -
-
+
From 0245e4bea96074501815f262d167f9ed0d66dbc1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 21 Jan 2026 15:17:23 +0200 Subject: [PATCH 15/53] Add Electric SQL replication for messages and comments --- ..._messages_comments_electric_replication.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 surfsense_backend/alembic/versions/74_add_messages_comments_electric_replication.py diff --git a/surfsense_backend/alembic/versions/74_add_messages_comments_electric_replication.py b/surfsense_backend/alembic/versions/74_add_messages_comments_electric_replication.py new file mode 100644 index 000000000..c06b4ed3d --- /dev/null +++ b/surfsense_backend/alembic/versions/74_add_messages_comments_electric_replication.py @@ -0,0 +1,94 @@ +"""Add new_chat_messages and chat_comments to Electric SQL publication + +Revision ID: 74 +Revises: 73 + +Enables real-time sync for chat messages and comments via Electric SQL. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "74" +down_revision: str | None = "73" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add new_chat_messages and chat_comments to Electric SQL replication.""" + # Set REPLICA IDENTITY FULL for Electric SQL sync + op.execute("ALTER TABLE new_chat_messages REPLICA IDENTITY FULL;") + op.execute("ALTER TABLE chat_comments REPLICA IDENTITY FULL;") + + # Add new_chat_messages to Electric publication + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'electric_publication_default' + AND tablename = 'new_chat_messages' + ) THEN + ALTER PUBLICATION electric_publication_default ADD TABLE new_chat_messages; + END IF; + END + $$; + """ + ) + + # Add chat_comments to Electric publication + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'electric_publication_default' + AND tablename = 'chat_comments' + ) THEN + ALTER PUBLICATION electric_publication_default ADD TABLE chat_comments; + END IF; + END + $$; + """ + ) + + +def downgrade() -> None: + """Remove new_chat_messages and chat_comments from Electric SQL replication.""" + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'electric_publication_default' + AND tablename = 'new_chat_messages' + ) THEN + ALTER PUBLICATION electric_publication_default DROP TABLE new_chat_messages; + END IF; + END + $$; + """ + ) + + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'electric_publication_default' + AND tablename = 'chat_comments' + ) THEN + ALTER PUBLICATION electric_publication_default DROP TABLE chat_comments; + END IF; + END + $$; + """ + ) + + # Note: Not reverting REPLICA IDENTITY as it doesn't harm normal operations From 93aa1dcf3c00efe4d4c49ebfdfd599f99ec1206b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:43:20 +0530 Subject: [PATCH 16/53] feat: implement inbox sidebar for enhanced user notifications - Introduced a new InboxSidebar component to manage and display inbox items. - Integrated real-time syncing of inbox items using Electric SQL for instant updates. - Added functionality to mark items as read, archive/unarchive, and filter inbox content. - Updated existing components to accommodate the new inbox feature, including layout adjustments and state management. - Enhanced user experience with improved navigation and interaction for inbox items. --- .../new-chat/[[...chat_id]]/page.tsx | 2 +- .../layout/providers/LayoutDataProvider.tsx | 53 +- .../components/layout/ui/header/Header.tsx | 3 - .../layout/ui/sidebar/InboxSidebar.tsx | 662 ++++++++++++++++++ .../layout/ui/sidebar/NavSection.tsx | 13 +- .../components/layout/ui/sidebar/index.ts | 1 + .../notifications/NotificationButton.tsx | 103 --- .../notifications/NotificationPopup.tsx | 246 ------- .../content/docs/how-to/electric-sql.mdx | 6 +- .../{notification.types.ts => inbox.types.ts} | 57 +- .../{use-notifications.ts => use-inbox.ts} | 125 ++-- surfsense_web/messages/en.json | 17 +- surfsense_web/messages/zh.json | 13 +- 13 files changed, 860 insertions(+), 441 deletions(-) create mode 100644 surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx delete mode 100644 surfsense_web/components/notifications/NotificationButton.tsx delete mode 100644 surfsense_web/components/notifications/NotificationPopup.tsx rename surfsense_web/contracts/types/{notification.types.ts => inbox.types.ts} (62%) rename surfsense_web/hooks/{use-notifications.ts => use-inbox.ts} (65%) 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 f23851f47..c59d50e08 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 @@ -367,7 +367,7 @@ export default function NewChatPage() { initializeThread(); }, [initializeThread]); - // Handle scroll to comment from URL query params (e.g., from notification click) + // Handle scroll to comment from URL query params (e.g., from inbox item click) const searchParams = useSearchParams(); const targetCommentId = searchParams.get("commentId"); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 489fde3d7..53f33f27b 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { Inbox, LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -19,6 +19,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useInbox } from "@/hooks/use-inbox"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; import { cleanupElectric } from "@/lib/electric/client"; @@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { LayoutShell } from "../ui/shell"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; +import { InboxSidebar } from "../ui/sidebar/InboxSidebar"; interface LayoutDataProviderProps { searchSpaceId: string; @@ -59,8 +61,8 @@ export function LayoutDataProvider({ ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) : null; - // Fetch current search space - const { data: searchSpace } = useQuery({ + // Fetch current search space (for caching purposes) + useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), enabled: !!searchSpaceId, @@ -77,9 +79,20 @@ export function LayoutDataProvider({ const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false); const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false); + // Inbox sidebar state + const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); + // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); + // Inbox hook + const userId = user?.id ? String(user.id) : null; + const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead, archiveItem } = useInbox( + userId, + Number(searchSpaceId) || null, + null + ); + // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); @@ -149,14 +162,21 @@ export function LayoutDataProvider({ icon: SquareLibrary, isActive: pathname?.includes("/documents"), }, + // { + // title: "Logs", + // url: `/dashboard/${searchSpaceId}/logs`, + // icon: Logs, + // isActive: pathname?.includes("/logs"), + // }, { - title: "Logs", - url: `/dashboard/${searchSpaceId}/logs`, - icon: Logs, - isActive: pathname?.includes("/logs"), + title: "Inbox", + url: "#inbox", // Special URL to indicate this is handled differently + icon: Inbox, + isActive: isInboxSidebarOpen, + badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined, }, ], - [searchSpaceId, pathname] + [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount] ); // Handlers @@ -248,6 +268,11 @@ export function LayoutDataProvider({ const handleNavItemClick = useCallback( (item: NavItem) => { + // Handle inbox specially - open sidebar instead of navigating + if (item.url === "#inbox") { + setIsInboxSidebarOpen(true); + return; + } router.push(item.url); }, [router] @@ -517,6 +542,18 @@ export function LayoutDataProvider({ searchSpaceId={searchSpaceId} /> + {/* Inbox Sidebar */} + + {/* Create Search Space Dialog */} - {/* Notifications */} - {/* Share button - only show on chat pages when thread exists */} {hasThread && ( diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx new file mode 100644 index 000000000..4171ac267 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -0,0 +1,662 @@ +"use client"; + +import { + AlertCircle, + Archive, + AtSign, + BellDot, + Check, + CheckCheck, + CheckCircle2, + History, + Inbox, + ListFilter, + Loader2, + MoreHorizontal, + RotateCcw, + Search, + X, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { InboxItem } from "@/hooks/use-inbox"; +import { cn } from "@/lib/utils"; + +/** + * Get initials from name or email for avatar fallback + */ +function getInitials(name: string | null | undefined, email: string | null | undefined): string { + if (name) { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + } + if (email) { + const localPart = email.split("@")[0]; + return localPart.slice(0, 2).toUpperCase(); + } + return "U"; +} + +type InboxTab = "mentions" | "status"; +type InboxFilter = "all" | "unread" | "archived"; + +interface InboxSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + inboxItems: InboxItem[]; + unreadCount: number; + loading: boolean; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; + archiveItem: (id: number, archived: boolean) => Promise; + onCloseMobileSidebar?: () => void; +} + +export function InboxSidebar({ + open, + onOpenChange, + inboxItems, + unreadCount, + loading, + markAsRead, + markAllAsRead, + archiveItem, + onCloseMobileSidebar, +}: InboxSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + + const [searchQuery, setSearchQuery] = useState(""); + const [activeTab, setActiveTab] = useState("mentions"); + const [activeFilter, setActiveFilter] = useState("all"); + const [mounted, setMounted] = useState(false); + // Unified dropdown state: "filter" | "options" | number (item id) | null + const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | number | null>(null); + const [markingAsReadId, setMarkingAsReadId] = useState(null); + const [archivingItemId, setArchivingItemId] = useState(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + // Split items by type + const mentionItems = useMemo( + () => inboxItems.filter((item) => item.type === "new_mention"), + [inboxItems] + ); + + const statusItems = useMemo( + () => + inboxItems.filter( + (item) => item.type === "connector_indexing" || item.type === "document_processing" + ), + [inboxItems] + ); + + // Get items for current tab + const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems; + + // Filter items based on filter type and search query + const filteredItems = useMemo(() => { + let items = currentTabItems; + + // Apply filter + if (activeFilter === "all") { + // "Unread & read" shows all non-archived items + items = items.filter((item) => !(item as InboxItem & { archived?: boolean }).archived); + } else if (activeFilter === "unread") { + // "Unread" shows only unread non-archived items + items = items.filter((item) => !item.read && !(item as InboxItem & { archived?: boolean }).archived); + } else if (activeFilter === "archived") { + // "Archived" shows only archived items + items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived); + } + + // Apply search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + items = items.filter( + (item) => + item.title.toLowerCase().includes(query) || + item.message.toLowerCase().includes(query) + ); + } + + return items; + }, [currentTabItems, activeFilter, searchQuery]); + + // Count unread items per tab + const unreadMentionsCount = useMemo( + () => mentionItems.filter((item) => !item.read).length, + [mentionItems] + ); + + const unreadStatusCount = useMemo( + () => statusItems.filter((item) => !item.read).length, + [statusItems] + ); + + const handleItemClick = useCallback( + async (item: InboxItem) => { + if (!item.read) { + setMarkingAsReadId(item.id); + await markAsRead(item.id); + setMarkingAsReadId(null); + } + + if (item.type === "new_mention") { + const metadata = item.metadata as { + thread_id?: number; + comment_id?: number; + }; + const searchSpaceId = item.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}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } + } + }, + [markAsRead, router, onOpenChange, onCloseMobileSidebar] + ); + + const handleMarkAsRead = useCallback( + async (itemId: number) => { + setMarkingAsReadId(itemId); + await markAsRead(itemId); + setMarkingAsReadId(null); + }, + [markAsRead] + ); + + const handleMarkAllAsRead = useCallback(async () => { + await markAllAsRead(); + }, [markAllAsRead]); + + const handleToggleArchive = useCallback( + async (itemId: number, currentlyArchived: boolean) => { + setArchivingItemId(itemId); + await archiveItem(itemId, !currentlyArchived); + setArchivingItemId(null); + }, + [archiveItem] + ); + + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + const formatTime = (dateString: string) => { + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + return `${Math.floor(diffDays / 7)}w`; + } catch { + return "now"; + } + }; + + const getStatusIcon = (item: InboxItem) => { + // For mentions, show the author's avatar with initials fallback + if (item.type === "new_mention") { + const metadata = item.metadata as { + author_name?: string; + author_avatar_url?: string | null; + author_email?: string; + }; + const authorName = metadata?.author_name; + const avatarUrl = metadata?.author_avatar_url; + const authorEmail = metadata?.author_email; + + return ( + + {avatarUrl && } + + {getInitials(authorName, authorEmail)} + + + ); + } + + // For status items (connector/document), show status icons + const status = item.metadata?.status as string | undefined; + + switch (status) { + case "in_progress": + return ( +
+ +
+ ); + case "completed": + return ( +
+ +
+ ); + case "failed": + return ( +
+ +
+ ); + default: + return ( +
+ +
+ ); + } + }; + + const getEmptyStateMessage = () => { + if (activeTab === "mentions") { + return { + title: t("no_mentions") || "No mentions", + hint: t("no_mentions_hint") || "You'll see mentions from others here", + }; + } + return { + title: t("no_status_updates") || "No status updates", + hint: t("no_status_updates_hint") || "Document and connector updates will appear here", + }; + }; + + if (!mounted) return null; + + return createPortal( + + {open && ( + <> + onOpenChange(false)} + aria-hidden="true" + /> + + +
+
+
+ +

{t("inbox") || "Inbox"}

+
+
+ setOpenDropdown(isOpen ? "filter" : null)} + > + + + + + + {t("filter") || "Filter"} + + setActiveFilter("all")} + className="flex items-center justify-between" + > + + + {t("unread_and_read") || "Unread & read"} + + {activeFilter === "all" && } + + setActiveFilter("unread")} + className="flex items-center justify-between" + > + + + {t("unread") || "Unread"} + + {activeFilter === "unread" && } + + setActiveFilter("archived")} + className="flex items-center justify-between" + > + + + {t("archived") || "Archived"} + + {activeFilter === "archived" && } + + + + setOpenDropdown(isOpen ? "options" : null)} + > + + + + + + + {t("mark_all_read") || "Mark all as read"} + + + +
+
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + setActiveTab(value as InboxTab)} + className="shrink-0 mx-4" + > + + + + + {t("mentions") || "Mentions"} + {unreadMentionsCount > 0 && ( + + {unreadMentionsCount} + + )} + + + + + + {t("status") || "Status"} + {unreadStatusCount > 0 && ( + + {unreadStatusCount} + + )} + + + + + +
+ {loading ? ( +
+ +
+ ) : filteredItems.length > 0 ? ( +
+ {filteredItems.map((item) => { + const isMarkingAsRead = markingAsReadId === item.id; + const isArchiving = archivingItemId === item.id; + const isBusy = isMarkingAsRead || isArchiving; + const isArchived = (item as InboxItem & { archived?: boolean }).archived; + + return ( +
+ + + + + +

{item.title}

+

+ {convertRenderedToDisplay(item.message)} +

+
+
+ + {/* Time/dot and 3-dot button container - swap on hover */} +
+ {/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */} +
+ + {formatTime(item.created_at)} + + {!item.read && ( + + )} +
+ + {/* 3-dot menu - hidden by default, visible on hover or when dropdown is open */} + + setOpenDropdown(isOpen ? item.id : null) + } + > + + + + + {!item.read && ( + <> + handleMarkAsRead(item.id)} + disabled={isBusy} + > + + {t("mark_as_read") || "Mark as read"} + + + + )} + handleToggleArchive(item.id, !!isArchived)} + disabled={isArchiving} + > + {isArchived ? ( + <> + + {t("unarchive") || "Restore"} + + ) : ( + <> + + {t("archive") || "Archive"} + + )} + + + +
+
+ ); + })} +
+ ) : searchQuery ? ( +
+ +

+ {t("no_results_found") || "No results found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ {activeTab === "mentions" ? ( + + ) : ( + + )} +

+ {getEmptyStateMessage().title} +

+

+ {getEmptyStateMessage().hint} +

+
+ )} +
+
+ + )} +
, + document.body + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index 7b694055b..d2d926de8 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti type="button" onClick={() => onItemClick?.(item)} className={cn( - "flex h-10 w-10 items-center justify-center rounded-md transition-colors", + "relative flex h-10 w-10 items-center justify-center rounded-md transition-colors", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", item.isActive && "bg-accent text-accent-foreground" @@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti {...joyrideAttr} > + {item.badge && ( + + {item.badge} + + )} {item.title} @@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti > {item.title} - {item.badge && {item.badge}} + {item.badge && ( + + {item.badge} + + )} ); })} diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index 282e4740b..d9c5edee5 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,6 +1,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { ChatListItem } from "./ChatListItem"; +export { InboxSidebar } from "./InboxSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; export { PageUsageDisplay } from "./PageUsageDisplay"; diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx deleted file mode 100644 index 020fea506..000000000 --- a/surfsense_web/components/notifications/NotificationButton.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { Bell } from "lucide-react"; -import { useParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications"; -import { cn } from "@/lib/utils"; -import { NotificationPopup } from "./NotificationPopup"; - -const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter"; - -export function NotificationButton() { - const [open, setOpen] = useState(false); - const { data: user } = useAtomValue(currentUserAtom); - const params = useParams(); - - // Filter state - null means show all, otherwise filter by type - const [activeFilter, setActiveFilter] = useState(null); - - // Load filter from localStorage on mount - useEffect(() => { - try { - const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - if ( - parsed === null || - ["new_mention", "connector_indexing", "document_processing"].includes(parsed) - ) { - setActiveFilter(parsed); - } - } - } catch { - // Ignore localStorage errors - } - }, []); - - // Handle filter toggle - clicking same pill again shows all - const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => { - setActiveFilter((current) => { - const newFilter = current === filter ? null : filter; - try { - localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter)); - } catch { - // Ignore localStorage errors - } - return newFilter; - }); - }, []); - - const userId = user?.id ? String(user.id) : null; - // Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/ - const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; - - const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications( - userId, - searchSpaceId, - activeFilter - ); - - return ( - - - - - - - - Notifications - - - setOpen(false)} - activeFilter={activeFilter} - onFilterChange={handleFilterChange} - /> - - - ); -} diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx deleted file mode 100644 index fbb756a00..000000000 --- a/surfsense_web/components/notifications/NotificationPopup.tsx +++ /dev/null @@ -1,246 +0,0 @@ -"use client"; - -import { formatDistanceToNow } from "date-fns"; -import { - AlertCircle, - AtSign, - Bell, - Cable, - CheckCheck, - CheckCircle2, - FileText, - Loader2, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications"; -import { cn } from "@/lib/utils"; - -/** - * Filter configuration for notification types - */ -const NOTIFICATION_FILTERS = { - new_mention: { label: "Mentions", icon: AtSign }, - connector_indexing: { label: "Connectors", icon: Cable }, - document_processing: { label: "Documents", icon: FileText }, -} as const; - -/** - * Get initials from name or email for avatar fallback - */ -function getInitials(name: string | null | undefined, email: string | null | undefined): string { - if (name) { - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2); - } - if (email) { - const localPart = email.split("@")[0]; - return localPart.slice(0, 2).toUpperCase(); - } - return "U"; -} - -interface NotificationPopupProps { - notifications: Notification[]; - unreadCount: number; - loading: boolean; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; - onClose?: () => void; - activeFilter: NotificationTypeEnum | null; - onFilterChange: (filter: NotificationTypeEnum | null) => void; -} - -export function NotificationPopup({ - notifications, - unreadCount, - loading, - markAsRead, - markAllAsRead, - onClose, - activeFilter, - onFilterChange, -}: NotificationPopupProps) { - 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 }); - } catch { - return "Recently"; - } - }; - - const getStatusIcon = (notification: Notification) => { - // For mentions, show the author's avatar with initials fallback - if (notification.type === "new_mention") { - const metadata = notification.metadata as { - author_name?: string; - author_avatar_url?: string | null; - author_email?: string; - }; - const authorName = metadata?.author_name; - const avatarUrl = metadata?.author_avatar_url; - const authorEmail = metadata?.author_email; - - return ( - - {avatarUrl && } - - {getInitials(authorName, authorEmail)} - - - ); - } - - // For other notification types, show status icons - const status = notification.metadata?.status as string | undefined; - - switch (status) { - case "in_progress": - return ; - case "completed": - return ; - case "failed": - return ; - default: - return ; - } - }; - - return ( -
- {/* Header */} -
-
-

Notifications

-
- {unreadCount > 0 && ( - - )} -
- - {/* Filter Pills */} -
- {( - Object.entries(NOTIFICATION_FILTERS) as [ - NotificationTypeEnum, - (typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS], - ][] - ).map(([key, { label, icon: Icon }]) => { - const isActive = activeFilter === key; - return ( - - ); - })} -
- - {/* Notifications List */} - - {loading ? ( -
- -
- ) : notifications.length === 0 ? ( -
- -

No notifications

-
- ) : ( -
- {notifications.map((notification, index) => ( -
- - {index < notifications.length - 1 && } -
- ))} -
- )} -
-
- ); -} diff --git a/surfsense_web/content/docs/how-to/electric-sql.mdx b/surfsense_web/content/docs/how-to/electric-sql.mdx index 54244c19b..288745850 100644 --- a/surfsense_web/content/docs/how-to/electric-sql.mdx +++ b/surfsense_web/content/docs/how-to/electric-sql.mdx @@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS # Electric SQL -[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL. +[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL. ## What Does Electric SQL Do? -When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this: +When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this: 1. Backend writes data to PostgreSQL 2. Electric SQL detects changes and streams them to the frontend @@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t This means: -- **Notifications appear instantly** - No need to refresh the page +- **Inbox updates appear instantly** - No need to refresh the page - **Document indexing progress updates live** - Watch your documents get processed - **Connector status syncs automatically** - See when connectors finish syncing - **Offline support** - PGlite caches data locally, so previously loaded data remains accessible diff --git a/surfsense_web/contracts/types/notification.types.ts b/surfsense_web/contracts/types/inbox.types.ts similarity index 62% rename from surfsense_web/contracts/types/notification.types.ts rename to surfsense_web/contracts/types/inbox.types.ts index b2b39d26e..515ba5864 100644 --- a/surfsense_web/contracts/types/notification.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -3,18 +3,18 @@ import { searchSourceConnectorTypeEnum } from "./connector.types"; import { documentTypeEnum } from "./document.types"; /** - * Notification type enum - matches backend notification types + * Inbox item type enum - matches backend notification types */ -export const notificationTypeEnum = z.enum([ +export const inboxItemTypeEnum = z.enum([ "connector_indexing", "document_processing", "new_mention", ]); /** - * Notification status enum - used in metadata + * Inbox item status enum - used in metadata */ -export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]); +export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]); /** * Document processing stage enum @@ -30,11 +30,11 @@ export const documentProcessingStageEnum = z.enum([ ]); /** - * Base metadata schema shared across notification types + * Base metadata schema shared across inbox item types */ -export const baseNotificationMetadata = z.object({ +export const baseInboxItemMetadata = z.object({ operation_id: z.string().optional(), - status: notificationStatusEnum.optional(), + status: inboxItemStatusEnum.optional(), started_at: z.string().optional(), completed_at: z.string().optional(), }); @@ -42,7 +42,7 @@ export const baseNotificationMetadata = z.object({ /** * Connector indexing metadata schema */ -export const connectorIndexingMetadata = baseNotificationMetadata.extend({ +export const connectorIndexingMetadata = baseInboxItemMetadata.extend({ connector_id: z.number(), connector_name: z.string(), connector_type: searchSourceConnectorTypeEnum, @@ -62,7 +62,7 @@ export const connectorIndexingMetadata = baseNotificationMetadata.extend({ /** * Document processing metadata schema */ -export const documentProcessingMetadata = baseNotificationMetadata.extend({ +export const documentProcessingMetadata = baseInboxItemMetadata.extend({ document_type: documentTypeEnum, document_name: z.string(), processing_stage: documentProcessingStageEnum, @@ -89,24 +89,24 @@ export const newMentionMetadata = z.object({ }); /** - * Union of all notification metadata types - * Use this when the notification type is unknown + * Union of all inbox item metadata types + * Use this when the inbox item type is unknown */ -export const notificationMetadata = z.union([ +export const inboxItemMetadata = z.union([ connectorIndexingMetadata, documentProcessingMetadata, newMentionMetadata, - baseNotificationMetadata, + baseInboxItemMetadata, ]); /** - * Main notification schema + * Main inbox item schema */ -export const notification = z.object({ +export const inboxItem = z.object({ id: z.number(), user_id: z.string(), search_space_id: z.number().nullable(), - type: notificationTypeEnum, + type: inboxItemTypeEnum, title: z.string(), message: z.string(), read: z.boolean(), @@ -116,33 +116,34 @@ export const notification = z.object({ }); /** - * Typed notification schemas for specific notification types + * Typed inbox item schemas for specific types */ -export const connectorIndexingNotification = notification.extend({ +export const connectorIndexingInboxItem = inboxItem.extend({ type: z.literal("connector_indexing"), metadata: connectorIndexingMetadata, }); -export const documentProcessingNotification = notification.extend({ +export const documentProcessingInboxItem = inboxItem.extend({ type: z.literal("document_processing"), metadata: documentProcessingMetadata, }); -export const newMentionNotification = notification.extend({ +export const newMentionInboxItem = inboxItem.extend({ type: z.literal("new_mention"), metadata: newMentionMetadata, }); // Inferred types -export type NotificationTypeEnum = z.infer; -export type NotificationStatusEnum = z.infer; +export type InboxItemTypeEnum = z.infer; +export type InboxItemStatusEnum = z.infer; export type DocumentProcessingStageEnum = z.infer; -export type BaseNotificationMetadata = z.infer; +export type BaseInboxItemMetadata = 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; +export type InboxItemMetadata = z.infer; +export type InboxItem = z.infer; +export type ConnectorIndexingInboxItem = z.infer; +export type DocumentProcessingInboxItem = z.infer; +export type NewMentionInboxItem = z.infer; + diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-inbox.ts similarity index 65% rename from surfsense_web/hooks/use-notifications.ts rename to surfsense_web/hooks/use-inbox.ts index eca00a935..afd3675ce 100644 --- a/surfsense_web/hooks/use-notifications.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -1,38 +1,38 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types"; +import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; import { authenticatedFetch } from "@/lib/auth-utils"; import type { SyncHandle } from "@/lib/electric/client"; import { useElectricClient } from "@/lib/electric/context"; -export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types"; +export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; /** - * Hook for managing notifications with Electric SQL real-time sync + * Hook for managing inbox items with Electric SQL real-time sync * * Uses the Electric client from context (provided by ElectricProvider) * instead of initializing its own - prevents race conditions and memory leaks * * Architecture: - * - User-level sync: Syncs ALL notifications for a user (runs once per user) - * - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change) + * - User-level sync: Syncs ALL inbox items for a user (runs once per user) + * - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change) * * This separation ensures smooth transitions when switching search spaces (no flash). * - * @param userId - The user ID to fetch notifications for - * @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only) - * @param typeFilter - Optional notification type to filter by (null shows all types) + * @param userId - The user ID to fetch inbox items for + * @param searchSpaceId - The search space ID to filter inbox items (null shows global items only) + * @param typeFilter - Optional inbox item type to filter by (null shows all types) */ -export function useNotifications( +export function useInbox( userId: string | null, searchSpaceId: number | null, - typeFilter: NotificationTypeEnum | null = null + typeFilter: InboxItemTypeEnum | null = null ) { // Get Electric client from context - ElectricProvider handles initialization const electricClient = useElectricClient(); - const [notifications, setNotifications] = useState([]); + const [inboxItems, setInboxItems] = useState([]); const [totalUnreadCount, setTotalUnreadCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -43,34 +43,37 @@ export function useNotifications( // Track user-level sync key to prevent duplicate sync subscriptions const userSyncKeyRef = useRef(null); - // EFFECT 1: User-level sync - runs once per user, syncs ALL notifications + // EFFECT 1: User-level sync - runs once per user, syncs ALL inbox items useEffect(() => { if (!userId || !electricClient) { setLoading(!electricClient); return; } - const userSyncKey = `notifications_${userId}`; + const userSyncKey = `inbox_${userId}`; if (userSyncKeyRef.current === userSyncKey) { // Already syncing for this user return; } + // Capture electricClient to satisfy TypeScript in async function + const client = electricClient; let mounted = true; userSyncKeyRef.current = userSyncKey; async function startUserSync() { try { - console.log("[useNotifications] Starting user-level sync for:", userId); + console.log("[useInbox] Starting user-level sync for:", userId); - // Sync ALL notifications for this user (cached via syncShape caching) - const handle = await electricClient.syncShape({ + // Sync ALL inbox items for this user (cached via syncShape caching) + // Note: Backend table is still named "notifications" + const handle = await client.syncShape({ table: "notifications", where: `user_id = '${userId}'`, primaryKey: ["id"], }); - console.log("[useNotifications] User sync started:", { + console.log("[useInbox] User sync started:", { isUpToDate: handle.isUpToDate, }); @@ -82,7 +85,7 @@ export function useNotifications( new Promise((resolve) => setTimeout(resolve, 2000)), ]); } catch (syncErr) { - console.error("[useNotifications] Initial sync failed:", syncErr); + console.error("[useInbox] Initial sync failed:", syncErr); } } @@ -96,8 +99,8 @@ export function useNotifications( setError(null); } catch (err) { if (!mounted) return; - console.error("[useNotifications] Failed to start user sync:", err); - setError(err instanceof Error ? err : new Error("Failed to sync notifications")); + console.error("[useInbox] Failed to start user sync:", err); + setError(err instanceof Error ? err : new Error("Failed to sync inbox")); setLoading(false); } } @@ -122,10 +125,12 @@ export function useNotifications( return; } + // Capture electricClient to satisfy TypeScript in async function + const client = electricClient; let mounted = true; async function updateQuery() { - // Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive) + // Clean up previous live query (but DON'T clear inbox items - keep showing old until new arrive) if (liveQueryRef.current) { liveQueryRef.current.unsubscribe(); liveQueryRef.current = null; @@ -133,13 +138,14 @@ export function useNotifications( try { console.log( - "[useNotifications] Updating query for searchSpace:", + "[useInbox] Updating query for searchSpace:", searchSpaceId, "typeFilter:", typeFilter ); // Build query with optional type filter + // Note: Backend table is still named "notifications" const baseQuery = `SELECT * FROM notifications WHERE user_id = $1 AND (search_space_id = $2 OR search_space_id IS NULL)`; @@ -148,16 +154,15 @@ export function useNotifications( const fullQuery = baseQuery + typeClause + orderClause; const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; - // Fetch notifications for current search space immediately - const result = await electricClient.db.query(fullQuery, params); + // Fetch inbox items for current search space immediately + const result = await client.db.query(fullQuery, params); if (mounted) { - setNotifications(result.rows || []); + setInboxItems(result.rows || []); } // Set up live query for real-time updates - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = electricClient.db as any; + const db = client.db as any; if (db.live?.query && typeof db.live.query === "function") { const liveQuery = await db.live.query(fullQuery, params); @@ -169,16 +174,16 @@ export function useNotifications( // Set initial results from live query if (liveQuery.initialResults?.rows) { - setNotifications(liveQuery.initialResults.rows); + setInboxItems(liveQuery.initialResults.rows); } else if (liveQuery.rows) { - setNotifications(liveQuery.rows); + setInboxItems(liveQuery.rows); } // Subscribe to changes if (typeof liveQuery.subscribe === "function") { - liveQuery.subscribe((result: { rows: Notification[] }) => { + liveQuery.subscribe((result: { rows: InboxItem[] }) => { if (mounted && result.rows) { - setNotifications(result.rows); + setInboxItems(result.rows); } }); } @@ -188,7 +193,7 @@ export function useNotifications( } } } catch (err) { - console.error("[useNotifications] Failed to update query:", err); + console.error("[useInbox] Failed to update query:", err); } } @@ -210,6 +215,8 @@ export function useNotifications( return; } + // Capture electricClient to satisfy TypeScript in async function + const client = electricClient; let mounted = true; async function updateUnreadCount() { @@ -220,13 +227,14 @@ export function useNotifications( } try { + // Note: Backend table is still named "notifications" const countQuery = `SELECT COUNT(*) as count FROM notifications WHERE user_id = $1 AND (search_space_id = $2 OR search_space_id IS NULL) AND read = false`; // Fetch initial count - const result = await electricClient.db.query<{ count: number }>(countQuery, [ + const result = await client.db.query<{ count: number }>(countQuery, [ userId, searchSpaceId, ]); @@ -236,8 +244,7 @@ export function useNotifications( } // Set up live query for real-time updates - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = electricClient.db as any; + const db = client.db as any; if (db.live?.query && typeof db.live.query === "function") { const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]); @@ -268,7 +275,7 @@ export function useNotifications( } } } catch (err) { - console.error("[useNotifications] Failed to update unread count:", err); + console.error("[useInbox] Failed to update unread count:", err); } } @@ -283,29 +290,31 @@ export function useNotifications( }; }, [userId, searchSpaceId, electricClient]); - // Mark notification as read via backend API - const markAsRead = useCallback(async (notificationId: number) => { + // Mark inbox item as read via backend API + const markAsRead = useCallback(async (itemId: number) => { try { + // Note: Backend API endpoint is still /notifications/ const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`, + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`, { method: "PATCH" } ); if (!response.ok) { const error = await response.json().catch(() => ({ detail: "Failed to mark as read" })); - throw new Error(error.detail || "Failed to mark notification as read"); + throw new Error(error.detail || "Failed to mark inbox item as read"); } return true; } catch (err) { - console.error("Failed to mark notification as read:", err); + console.error("Failed to mark inbox item as read:", err); return false; } }, []); - // Mark all notifications as read via backend API + // Mark all inbox items as read via backend API const markAllAsRead = useCallback(async () => { try { + // Note: Backend API endpoint is still /notifications/ const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, { method: "PATCH" } @@ -313,22 +322,48 @@ export function useNotifications( if (!response.ok) { const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" })); - throw new Error(error.detail || "Failed to mark all notifications as read"); + throw new Error(error.detail || "Failed to mark all inbox items as read"); } return true; } catch (err) { - console.error("Failed to mark all notifications as read:", err); + console.error("Failed to mark all inbox items as read:", err); + return false; + } + }, []); + + // Archive/unarchive an inbox item via backend API + const archiveItem = useCallback(async (itemId: number, archived: boolean) => { + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/archive`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archived }), + } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to update archive status" })); + throw new Error(error.detail || "Failed to update inbox item archive status"); + } + + return true; + } catch (err) { + console.error("Failed to update inbox item archive status:", err); return false; } }, []); return { - notifications, + inboxItems, unreadCount: totalUnreadCount, markAsRead, markAllAsRead, + archiveItem, loading, error, }; } + diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index cda522b61..59d948769 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -692,7 +692,22 @@ "light": "Light", "dark": "Dark", "system": "System", - "logout": "Logout" + "logout": "Logout", + "inbox": "Inbox", + "search_inbox": "Search inbox", + "mark_all_read": "Mark all as read", + "mark_as_read": "Mark as read", + "mentions": "Mentions", + "status": "Status", + "no_results_found": "No results found", + "no_mentions": "No mentions", + "no_mentions_hint": "You'll see mentions from others here", + "no_status_updates": "No status updates", + "no_status_updates_hint": "Document and connector updates will appear here", + "filter": "Filter", + "unread_and_read": "Unread & read", + "unread": "Unread", + "archived": "Archived" }, "errors": { "something_went_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 7f2f49cfc..09b080c27 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -677,7 +677,18 @@ "light": "浅色", "dark": "深色", "system": "系统", - "logout": "退出登录" + "logout": "退出登录", + "inbox": "收件箱", + "search_inbox": "搜索收件箱...", + "mark_all_read": "全部标记为已读", + "mark_as_read": "标记为已读", + "mentions": "提及", + "status": "状态", + "no_results_found": "未找到结果", + "no_mentions": "没有提及", + "no_mentions_hint": "您会在这里看到他人的提及", + "no_status_updates": "没有状态更新", + "no_status_updates_hint": "文档和连接器更新将显示在这里" }, "errors": { "something_went_wrong": "出错了", From 22b2d6e400412296b4e3821bf02e0b3de4cb0367 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:34:58 +0530 Subject: [PATCH 17/53] feat: add archived column to notifications and implement archiving functionality - Introduced an archived boolean column in the notifications table to allow users to archive inbox items without deletion. - Updated Notification model to include the archived field with default value. - Added ArchiveRequest and ArchiveResponse models for handling archive/unarchive operations. - Implemented API endpoint to archive or unarchive notifications, ensuring real-time updates with Electric SQL. - Enhanced InboxSidebar to filter and display archived notifications appropriately. --- ...73_add_archived_column_to_notifications.py | 51 ++++++++++++++ surfsense_backend/app/db.py | 3 + .../app/routes/notifications_routes.py | 51 ++++++++++++++ .../ui/sidebar/AllPrivateChatsSidebar.tsx | 17 +---- .../ui/sidebar/AllSharedChatsSidebar.tsx | 17 +---- .../layout/ui/sidebar/InboxSidebar.tsx | 70 ++++++++++++++++--- surfsense_web/contracts/types/inbox.types.ts | 1 + surfsense_web/lib/electric/client.ts | 7 +- 8 files changed, 178 insertions(+), 39 deletions(-) create mode 100644 surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py diff --git a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py new file mode 100644 index 000000000..99962e501 --- /dev/null +++ b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py @@ -0,0 +1,51 @@ +"""Add archived column to notifications table + +Revision ID: 73 +Revises: 72 + +Adds an archived boolean column to the notifications table to allow users +to archive inbox items without deleting them. + +NOTE: Electric SQL automatically picks up schema changes when REPLICA IDENTITY FULL +is set (which was done in migration 66). We re-affirm it here to ensure replication +continues to work after adding the new column. +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "73" +down_revision: str | None = "72" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add archived column to notifications table.""" + # Add the archived column with a default value + op.execute( + """ + ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + # Create index for archived column + op.execute( + "CREATE INDEX IF NOT EXISTS ix_notifications_archived ON notifications (archived);" + ) + + # Re-affirm REPLICA IDENTITY FULL for Electric SQL after schema change + # This ensures Electric SQL continues to replicate all columns including the new one + op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;") + + +def downgrade() -> None: + """Remove archived column from notifications table.""" + op.execute("DROP INDEX IF EXISTS ix_notifications_archived;") + op.execute("ALTER TABLE notifications DROP COLUMN IF EXISTS archived;") + # Re-affirm REPLICA IDENTITY FULL after removing the column + op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;") + diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 38e27ecf2..b969f9e55 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -784,6 +784,9 @@ class Notification(BaseModel, TimestampMixin): read = Column( Boolean, nullable=False, default=False, server_default=text("false"), index=True ) + archived = Column( + Boolean, nullable=False, default=False, server_default=text("false"), index=True + ) notification_metadata = Column("metadata", JSONB, nullable=True, default={}) updated_at = Column( TIMESTAMP(timezone=True), diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index deee748d8..3bf7a4880 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -30,6 +30,19 @@ class MarkAllReadResponse(BaseModel): updated_count: int +class ArchiveRequest(BaseModel): + """Request body for archive/unarchive operations.""" + + archived: bool + + +class ArchiveResponse(BaseModel): + """Response for archive operations.""" + + success: bool + message: str + + @router.patch("/{notification_id}/read", response_model=MarkReadResponse) async def mark_notification_as_read( notification_id: int, @@ -100,3 +113,41 @@ async def mark_all_notifications_as_read( message=f"Marked {updated_count} notification(s) as read", updated_count=updated_count, ) + + +@router.patch("/{notification_id}/archive", response_model=ArchiveResponse) +async def archive_notification( + notification_id: int, + request: ArchiveRequest, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> ArchiveResponse: + """ + Archive or unarchive a notification. + + Electric SQL will automatically sync this change to all connected clients. + """ + # Verify the notification belongs to the user + result = await session.execute( + select(Notification).where( + Notification.id == notification_id, + Notification.user_id == user.id, + ) + ) + notification = result.scalar_one_or_none() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found", + ) + + # Update the notification + notification.archived = request.archived + await session.commit() + + action = "archived" if request.archived else "unarchived" + return ArchiveResponse( + success=True, + message=f"Notification {action}", + ) diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index fb1a6ed0d..6be4809cf 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -238,20 +238,9 @@ export function AllPrivateChatsSidebar({ aria-label={t("chats") || "Private Chats"} >
-
-
- -

{t("chats") || "Private Chats"}

-
- +
+ +

{t("chats") || "Private Chats"}

diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index f400e6fc8..ea80cc920 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -238,20 +238,9 @@ export function AllSharedChatsSidebar({ aria-label={t("shared_chats") || "Shared Chats"} >
-
-
- -

{t("shared_chats") || "Shared Chats"}

-
- +
+ +

{t("shared_chats") || "Shared Chats"}

diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 4171ac267..9f5a50bc4 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -143,15 +143,16 @@ export function InboxSidebar({ let items = currentTabItems; // Apply filter + // Note: Use `item.archived === true` to handle undefined/null as false if (activeFilter === "all") { // "Unread & read" shows all non-archived items - items = items.filter((item) => !(item as InboxItem & { archived?: boolean }).archived); + items = items.filter((item) => item.archived !== true); } else if (activeFilter === "unread") { // "Unread" shows only unread non-archived items - items = items.filter((item) => !item.read && !(item as InboxItem & { archived?: boolean }).archived); + items = items.filter((item) => !item.read && item.archived !== true); } else if (activeFilter === "archived") { - // "Archived" shows only archived items - items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived); + // "Archived" shows only archived items (must be explicitly true) + items = items.filter((item) => item.archived === true); } // Apply search query @@ -340,7 +341,7 @@ export function InboxSidebar({ animate={{ x: 0 }} exit={{ x: "-100%" }} transition={{ type: "spring", damping: 25, stiffness: 300 }} - className="fixed inset-y-0 left-0 z-70 w-96 bg-background shadow-xl flex flex-col pointer-events-auto isolate" + className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate" role="dialog" aria-modal="true" aria-label={t("inbox") || "Inbox"} @@ -500,7 +501,7 @@ export function InboxSidebar({ const isMarkingAsRead = markingAsReadId === item.id; const isArchiving = archivingItemId === item.id; const isBusy = isMarkingAsRead || isArchiving; - const isArchived = (item as InboxItem & { archived?: boolean }).archived; + const isArchived = item.archived === true; return (
{convertRenderedToDisplay(item.message)}

+ {/* Mobile action buttons - shown below description on mobile only */} +
+ {!item.read && ( + + )} + +
@@ -544,8 +586,8 @@ export function InboxSidebar({ - {/* Time/dot and 3-dot button container - swap on hover */} -
+ {/* Time/dot and 3-dot button container - swap on hover (desktop only) */} +
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
)} handleToggleArchive(item.id, !!isArchived)} + onClick={() => handleToggleArchive(item.id, isArchived)} disabled={isArchiving} > {isArchived ? ( @@ -623,6 +665,16 @@ export function InboxSidebar({
+ + {/* Mobile time and unread dot - always visible on mobile */} +
+ + {formatTime(item.created_at)} + + {!item.read && ( + + )} +
); })} diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 515ba5864..2e80a9909 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -110,6 +110,7 @@ export const inboxItem = z.object({ title: z.string(), message: z.string(), read: z.boolean(), + archived: z.boolean().default(false), metadata: z.record(z.string(), z.unknown()), created_at: z.string(), updated_at: z.string().nullable(), diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 514185d23..222553f32 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -53,8 +53,9 @@ const activeSyncHandles = new Map(); 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; +// v2: user-specific database architecture +// v3: added archived column to notifications +const SYNC_VERSION = 3; // Database name prefix for identifying SurfSense databases const DB_PREFIX = "surfsense-"; @@ -181,6 +182,7 @@ export async function initElectric(userId: string): Promise { title TEXT NOT NULL, message TEXT NOT NULL, read BOOLEAN NOT NULL DEFAULT FALSE, + archived BOOLEAN NOT NULL DEFAULT FALSE, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ @@ -188,6 +190,7 @@ export async function initElectric(userId: string): Promise { CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read); + CREATE INDEX IF NOT EXISTS idx_notifications_archived ON notifications(archived); `); // Create the search_source_connectors table schema in PGlite From 3765d0a868cecd832a060ebe5c57ee1ca1da7666 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 21 Jan 2026 17:57:11 +0200 Subject: [PATCH 18/53] Update migration 74 for live collaboration tables --- ...ve_collaboration_tables_electric_replication.py} | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) rename surfsense_backend/alembic/versions/{74_add_messages_comments_electric_replication.py => 74_add_live_collaboration_tables_electric_replication.py} (82%) diff --git a/surfsense_backend/alembic/versions/74_add_messages_comments_electric_replication.py b/surfsense_backend/alembic/versions/74_add_live_collaboration_tables_electric_replication.py similarity index 82% rename from surfsense_backend/alembic/versions/74_add_messages_comments_electric_replication.py rename to surfsense_backend/alembic/versions/74_add_live_collaboration_tables_electric_replication.py index c06b4ed3d..c98ddd332 100644 --- a/surfsense_backend/alembic/versions/74_add_messages_comments_electric_replication.py +++ b/surfsense_backend/alembic/versions/74_add_live_collaboration_tables_electric_replication.py @@ -1,9 +1,14 @@ -"""Add new_chat_messages and chat_comments to Electric SQL publication +"""Add live collaboration tables to Electric SQL publication Revision ID: 74 Revises: 73 -Enables real-time sync for chat messages and comments via Electric SQL. +Enables real-time sync for live collaboration features: +- new_chat_messages: Live message sync between users +- chat_comments: Live comment updates + +Note: User/member info is fetched via API (membersAtom) for client-side joins, +not via Electric SQL, to keep where clauses optimized and reduce complexity. """ from collections.abc import Sequence @@ -17,7 +22,7 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: - """Add new_chat_messages and chat_comments to Electric SQL replication.""" + """Add live collaboration tables to Electric SQL replication.""" # Set REPLICA IDENTITY FULL for Electric SQL sync op.execute("ALTER TABLE new_chat_messages REPLICA IDENTITY FULL;") op.execute("ALTER TABLE chat_comments REPLICA IDENTITY FULL;") @@ -58,7 +63,7 @@ def upgrade() -> None: def downgrade() -> None: - """Remove new_chat_messages and chat_comments from Electric SQL replication.""" + """Remove live collaboration tables from Electric SQL replication.""" op.execute( """ DO $$ From 73ff26119434f045da174b9645a53ce1aa02ba2c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 21 Jan 2026 17:58:17 +0200 Subject: [PATCH 19/53] Add raw message/comment types and reduce members stale time --- .../atoms/members/members-query.atoms.ts | 2 +- .../contracts/types/chat-comments.types.ts | 14 ++++++++++++++ .../contracts/types/chat-messages.types.ts | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/contracts/types/chat-messages.types.ts diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts index 8ed56ef0c..f486dc02b 100644 --- a/surfsense_web/atoms/members/members-query.atoms.ts +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -9,7 +9,7 @@ export const membersAtom = atomWithQuery((get) => { return { queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""), enabled: !!searchSpaceId, - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration queryFn: async () => { if (!searchSpaceId) { return []; diff --git a/surfsense_web/contracts/types/chat-comments.types.ts b/surfsense_web/contracts/types/chat-comments.types.ts index 92b3ff060..c3b32be9d 100644 --- a/surfsense_web/contracts/types/chat-comments.types.ts +++ b/surfsense_web/contracts/types/chat-comments.types.ts @@ -1,5 +1,18 @@ import { z } from "zod"; +/** + * Raw comment + */ +export const rawComment = z.object({ + id: z.number(), + message_id: z.number(), + parent_id: z.number().nullable(), + author_id: z.string().nullable(), + content: z.string(), + created_at: z.string(), + updated_at: z.string(), +}); + export const author = z.object({ id: z.string().uuid(), display_name: z.string().nullable(), @@ -122,6 +135,7 @@ export const getMentionsResponse = z.object({ total_count: z.number(), }); +export type RawComment = z.infer; export type Author = z.infer; export type CommentReply = z.infer; export type Comment = z.infer; diff --git a/surfsense_web/contracts/types/chat-messages.types.ts b/surfsense_web/contracts/types/chat-messages.types.ts new file mode 100644 index 000000000..01edef3f2 --- /dev/null +++ b/surfsense_web/contracts/types/chat-messages.types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** + * Raw message from database (Electric SQL sync) + */ +export const rawMessage = z.object({ + id: z.number(), + thread_id: z.number(), + role: z.enum(["user", "assistant", "system"]), + content: z.unknown(), + author_id: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type RawMessage = z.infer; From dd781fa3d531a7cb19b2f63b4980a0ecc418ea11 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 21 Jan 2026 17:58:35 +0200 Subject: [PATCH 20/53] Add live sync hooks for messages and comments --- surfsense_web/hooks/use-chat-messages-live.ts | 77 +++++++++ surfsense_web/hooks/use-comments-live.ts | 154 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 surfsense_web/hooks/use-chat-messages-live.ts create mode 100644 surfsense_web/hooks/use-comments-live.ts diff --git a/surfsense_web/hooks/use-chat-messages-live.ts b/surfsense_web/hooks/use-chat-messages-live.ts new file mode 100644 index 000000000..39341d479 --- /dev/null +++ b/surfsense_web/hooks/use-chat-messages-live.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useShape } from "@electric-sql/react"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { membersAtom } from "@/atoms/members/members-query.atoms"; +import type { RawMessage } from "@/contracts/types/chat-messages.types"; +import type { Membership } from "@/contracts/types/members.types"; +import type { MessageRecord } from "@/lib/chat/thread-persistence"; + +const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; + +/** + * Member info for building author data - derived from Membership + */ +type MemberInfo = Pick; + +/** + * Hook to get live chat messages for real-time sync. + * Uses Electric SQL for messages + membersAtom (API) for author info. + */ +export function useChatMessagesLive(threadId: number | null) { + + const { + data: messagesData, + isLoading: messagesLoading, + isError: messagesError, + error: messagesErrorDetails, + } = useShape({ + url: `${ELECTRIC_URL}/v1/shape`, + params: { + table: "new_chat_messages", + where: `thread_id = ${threadId}`, + }, + }); + + + const { data: membersData, isLoading: membersLoading } = useAtomValue(membersAtom); + + + const messages = useMemo(() => { + if (!messagesData) return []; + + // Build member lookup map + const memberMap = new Map(); + if (membersData) { + for (const member of membersData) { + memberMap.set(member.user_id, { + user_display_name: member.user_display_name, + user_avatar_url: member.user_avatar_url, + }); + } + } + + // Transform raw messages to MessageRecord with author info + return [...messagesData].map((msg): MessageRecord => { + const author = msg.author_id ? memberMap.get(msg.author_id) : null; + return { + id: msg.id, + thread_id: msg.thread_id, + role: msg.role, + content: msg.content, + created_at: msg.created_at, + author_id: msg.author_id, + author_display_name: author?.user_display_name ?? null, + author_avatar_url: author?.user_avatar_url ?? null, + }; + }); + }, [messagesData, membersData]); + + return { + messages, + isLoading: messagesLoading || membersLoading, + isError: messagesError, + error: messagesError ? messagesErrorDetails : null, + }; +} diff --git a/surfsense_web/hooks/use-comments-live.ts b/surfsense_web/hooks/use-comments-live.ts new file mode 100644 index 000000000..f4d922888 --- /dev/null +++ b/surfsense_web/hooks/use-comments-live.ts @@ -0,0 +1,154 @@ +"use client"; + +import { useShape } from "@electric-sql/react"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import type { Comment, CommentReply, Author } from "@/contracts/types/chat-comments.types"; +import type { Membership } from "@/contracts/types/members.types"; +import type { RawComment } from "@/contracts/types/chat-comments.types"; + +const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; + +// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN) +const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g; + +/** + * Member info for building author objects - derived from Membership + */ +type MemberInfo = Pick; + +/** + * Render mentions in content by replacing @[uuid] with @{DisplayName} + */ +function renderMentions(content: string, memberMap: Map): string { + return content.replace(MENTION_PATTERN, (match, uuid) => { + const member = memberMap.get(uuid); + if (member?.user_display_name) { + return `@{${member.user_display_name}}`; + } + return match; + }); +} + +/** + * Hook to get live comments for a specific message. + * Uses Electric SQL for comments + membersAtom (API) for author info. + * Returns data matching the existing Comment type. + */ +export function useCommentsLive(messageId: number | null) { + const { + data: commentsData, + isLoading: commentsLoading, + isError: commentsError, + error: commentsErrorDetails, + } = useShape({ + url: `${ELECTRIC_URL}/v1/shape`, + params: { + table: "chat_comments", + where: `message_id = ${messageId}`, + }, + }); + + const { data: membersData, isLoading: membersLoading } = useAtomValue(membersAtom); + const { data: currentUser } = useAtomValue(currentUserAtom); + const { data: myAccess } = useAtomValue(myAccessAtom); + + const comments = useMemo(() => { + if (!commentsData) return []; + + // Build member lookup map + const memberMap = new Map(); + if (membersData) { + for (const member of membersData) { + memberMap.set(member.user_id, { + user_display_name: member.user_display_name, + user_avatar_url: member.user_avatar_url, + user_email: member.user_email, + }); + } + } + + const currentUserId = currentUser?.id; + const isOwnerOrAdmin = myAccess?.is_owner ?? false; + + // Build author object from member data + const buildAuthor = (authorId: string | null): Author | null => { + if (!authorId) return null; + const member = memberMap.get(authorId); + if (!member) return null; + return { + id: authorId, + display_name: member.user_display_name ?? null, + avatar_url: member.user_avatar_url ?? null, + email: member.user_email ?? "", + }; + }; + + // Transform raw comment to CommentReply + const transformToReply = (raw: RawComment): CommentReply => { + const isEdited = raw.created_at !== raw.updated_at; + const isAuthor = currentUserId === raw.author_id; + + return { + id: raw.id, + content: raw.content, + content_rendered: renderMentions(raw.content, memberMap), + author: buildAuthor(raw.author_id), + created_at: raw.created_at, + updated_at: raw.updated_at, + is_edited: isEdited, + can_edit: isAuthor, + can_delete: isAuthor || isOwnerOrAdmin, + }; + }; + + // Separate top-level comments and replies + const topLevelRaw: RawComment[] = []; + const repliesMap = new Map(); + + for (const raw of commentsData) { + if (raw.parent_id === null) { + topLevelRaw.push(raw); + } else { + const replies = repliesMap.get(raw.parent_id) || []; + replies.push(raw); + repliesMap.set(raw.parent_id, replies); + } + } + + // Transform top-level comments to Comment type + const transformToComment = (raw: RawComment): Comment => { + const isEdited = raw.created_at !== raw.updated_at; + const isAuthor = currentUserId === raw.author_id; + const rawReplies = repliesMap.get(raw.id) || []; + const replies = rawReplies.map(transformToReply); + + return { + id: raw.id, + message_id: raw.message_id, + content: raw.content, + content_rendered: renderMentions(raw.content, memberMap), + author: buildAuthor(raw.author_id), + created_at: raw.created_at, + updated_at: raw.updated_at, + is_edited: isEdited, + can_edit: isAuthor, + can_delete: isAuthor || isOwnerOrAdmin, + reply_count: replies.length, + replies, + }; + }; + + return topLevelRaw.map(transformToComment); + }, [commentsData, membersData, currentUser?.id, myAccess?.is_owner]); + + return { + comments, + commentCount: commentsData?.length ?? 0, + isLoading: commentsLoading || membersLoading, + isError: commentsError, + error: commentsError ? commentsErrorDetails : null, + }; +} From f7ee03ddf370110dd42614d545d3b3cf05b797cb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 21 Jan 2026 18:10:03 +0200 Subject: [PATCH 21/53] Integrate live message sync on chat page --- .../new-chat/[[...chat_id]]/page.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 43c33ba5a..865dd6e68 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 @@ -33,6 +33,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; +import { useChatMessagesLive } from "@/hooks/use-chat-messages-live"; import { getBearerToken } from "@/lib/auth-utils"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { @@ -257,6 +258,9 @@ export default function NewChatPage() { // Get current user for author info in shared chats const { data: currentUser } = useAtomValue(currentUserAtom); + // Live sync for other users' messages (shared chats) + const { messages: liveMessages } = useChatMessagesLive(threadId); + // Create the attachment adapter for file processing const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); @@ -409,6 +413,45 @@ export default function NewChatPage() { }); }, [currentThread, setCurrentThreadState]); + // Live sync: Merge messages from other users (shared chats) + useEffect(() => { + if (!liveMessages.length || !currentUser?.id || isRunning) return; + + // Get IDs of messages we already have locally + const localMessageIds = new Set( + messages + .map((m) => { + // Extract numeric ID from "msg-{id}" format + const match = m.id?.match(/^msg-(\d+)$/); + return match ? Number.parseInt(match[1], 10) : null; + }) + .filter((id): id is number => id !== null) + ); + + // Find live messages from OTHER users that we don't have locally + const newOtherUserMessages = liveMessages.filter((liveMsg) => { + // Skip if we already have this message + if (localMessageIds.has(liveMsg.id)) return false; + // Skip if this is our own message (we added it optimistically) + if (liveMsg.author_id === currentUser.id) return false; + return true; + }); + + if (newOtherUserMessages.length > 0) { + // Convert and add new messages from other users + const converted = newOtherUserMessages.map(convertToThreadMessage); + setMessages((prev) => { + // Merge and sort by ID to maintain order + const merged = [...prev, ...converted]; + return merged.sort((a, b) => { + const aId = Number.parseInt((a.id ?? "").replace(/^msg-/, ""), 10) || 0; + const bId = Number.parseInt((b.id ?? "").replace(/^msg-/, ""), 10) || 0; + return aId - bId; + }); + }); + } + }, [liveMessages, currentUser?.id, messages, isRunning]); + // Cancel ongoing request const cancelRun = useCallback(async () => { if (abortControllerRef.current) { From f3467262940e956e9e443dc944901e763ff77a26 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 21 Jan 2026 18:13:02 +0200 Subject: [PATCH 22/53] Integrate live comment sync in comment components --- .../components/assistant-ui/assistant-message.tsx | 10 +++------- .../comment-panel-container.tsx | 15 +++++++-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 681dc315a..513242d1b 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -25,7 +25,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger"; -import { useComments } from "@/hooks/use-comments"; +import { useCommentsLive } from "@/hooks/use-comments-live"; import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; @@ -115,12 +115,8 @@ export const AssistantMessage: FC = () => { const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; - const { data: commentsData } = useComments({ - messageId: dbMessageId ?? 0, - enabled: !!dbMessageId, - }); - - const commentCount = commentsData?.total_count ?? 0; + // Live sync for real-time comment count + const { commentCount } = useCommentsLive(dbMessageId); const hasComments = commentCount > 0; const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId; const showCommentPanel = hasComments || isAddingComment; diff --git a/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx b/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx index 197ac0798..8281ca8fd 100644 --- a/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx +++ b/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx @@ -10,7 +10,7 @@ import { } from "@/atoms/chat-comments/comments-mutation.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { useComments } from "@/hooks/use-comments"; +import { useCommentsLive } from "@/hooks/use-comments-live"; import { CommentPanel } from "../comment-panel/comment-panel"; import type { CommentPanelContainerProps } from "./types"; import { transformComment, transformMember } from "./utils"; @@ -21,10 +21,10 @@ export function CommentPanelContainer({ maxHeight, variant = "desktop", }: CommentPanelContainerProps) { - const { data: commentsData, isLoading: isCommentsLoading } = useComments({ - messageId, - enabled: isOpen, - }); + // Live sync for real-time comment updates + const { comments: liveComments, isLoading: isCommentsLoading } = useCommentsLive( + isOpen ? messageId : null + ); const [{ data: membersData, isLoading: isMembersLoading }] = useAtom(membersAtom); const [{ data: currentUser }] = useAtom(currentUserAtom); @@ -35,9 +35,8 @@ export function CommentPanelContainer({ const [{ mutate: deleteComment, isPending: isDeleting }] = useAtom(deleteCommentMutationAtom); const commentThreads = useMemo(() => { - if (!commentsData?.comments) return []; - return commentsData.comments.map(transformComment); - }, [commentsData]); + return liveComments.map(transformComment); + }, [liveComments]); const members = useMemo(() => { if (!membersData) return []; From 8dcdd27d1059b261e71082faddb60bc7d6259ad2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:22:28 +0530 Subject: [PATCH 23/53] fix: update sidebar components for consistent styling and improved unread item display - Changed text color for active and archived item counts in AllPrivateChatsSidebar and AllSharedChatsSidebar to use 'text-muted-foreground' for better visibility. - Replaced Loader2 with a new Spinner component in InboxSidebar for a consistent loading indicator. - Enhanced unread item count display in InboxSidebar to show zero when no unread items are present, improving user feedback. - Adjusted styles for MoreHorizontal and ListFilter icons in InboxSidebar to align with the updated design system. --- .../ui/sidebar/AllPrivateChatsSidebar.tsx | 6 +- .../ui/sidebar/AllSharedChatsSidebar.tsx | 6 +- .../layout/ui/sidebar/InboxSidebar.tsx | 74 ++++++++++--------- surfsense_web/components/ui/spinner.tsx | 35 +++++++++ 4 files changed, 81 insertions(+), 40 deletions(-) create mode 100644 surfsense_web/components/ui/spinner.tsx diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 6be4809cf..78bac3371 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -280,7 +280,7 @@ export function AllPrivateChatsSidebar({ Active - {activeCount} + {activeCount} Archived - {archivedCount} + {archivedCount} @@ -363,7 +363,7 @@ export function AllPrivateChatsSidebar({ {isDeleting ? ( ) : ( - + )} {t("more_options") || "More options"} diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index ea80cc920..e3b6174c3 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -280,7 +280,7 @@ export function AllSharedChatsSidebar({ Active - {activeCount} + {activeCount} Archived - {archivedCount} + {archivedCount} @@ -363,7 +363,7 @@ export function AllSharedChatsSidebar({ {isDeleting ? ( ) : ( - + )} {t("more_options") || "More options"} diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 9f5a50bc4..f81417a45 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -11,7 +11,6 @@ import { History, Inbox, ListFilter, - Loader2, MoreHorizontal, RotateCcw, Search, @@ -34,6 +33,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { InboxItem } from "@/hooks/use-inbox"; @@ -168,16 +168,24 @@ export function InboxSidebar({ return items; }, [currentTabItems, activeFilter, searchQuery]); - // Count unread items per tab - const unreadMentionsCount = useMemo( - () => mentionItems.filter((item) => !item.read).length, - [mentionItems] - ); + // Count unread items per tab (filter-aware) + const unreadMentionsCount = useMemo(() => { + if (activeFilter === "archived") { + // In archived view, show unread archived items + return mentionItems.filter((item) => !item.read && item.archived === true).length; + } + // For "all" and "unread" filters, show unread non-archived items + return mentionItems.filter((item) => !item.read && item.archived !== true).length; + }, [mentionItems, activeFilter]); - const unreadStatusCount = useMemo( - () => statusItems.filter((item) => !item.read).length, - [statusItems] - ); + const unreadStatusCount = useMemo(() => { + if (activeFilter === "archived") { + // In archived view, show unread archived items + return statusItems.filter((item) => !item.read && item.archived === true).length; + } + // For "all" and "unread" filters, show unread non-archived items + return statusItems.filter((item) => !item.read && item.archived !== true).length; + }, [statusItems, activeFilter]); const handleItemClick = useCallback( async (item: InboxItem) => { @@ -283,7 +291,7 @@ export function InboxSidebar({ case "in_progress": return (
- +
); case "completed": @@ -363,7 +371,7 @@ export function InboxSidebar({ size="icon" className="h-8 w-8 rounded-full" > - + {t("filter") || "Filter"} @@ -413,7 +421,7 @@ export function InboxSidebar({ size="icon" className="h-8 w-8 rounded-full" > - + {t("more_options") || "More options"} @@ -466,11 +474,12 @@ export function InboxSidebar({ {t("mentions") || "Mentions"} - {unreadMentionsCount > 0 && ( - - {unreadMentionsCount} - - )} + + {unreadMentionsCount || 0} + {t("status") || "Status"} - {unreadStatusCount > 0 && ( - - {unreadStatusCount} - - )} + + {unreadStatusCount || 0} + @@ -493,7 +503,7 @@ export function InboxSidebar({
{loading ? (
- +
) : filteredItems.length > 0 ? (
@@ -507,7 +517,7 @@ export function InboxSidebar({
- {isMarkingAsRead ? ( - - ) : ( - - )} + {t("mark_as_read") || "Mark as read"} )} @@ -566,7 +572,7 @@ export function InboxSidebar({ disabled={isArchiving} > {isArchiving ? ( - + ) : isArchived ? ( ) : ( @@ -623,10 +629,10 @@ export function InboxSidebar({ )} disabled={isBusy} > - {isBusy ? ( - + {isArchiving ? ( + ) : ( - + )} {t("more_options") || "More options"} diff --git a/surfsense_web/components/ui/spinner.tsx b/surfsense_web/components/ui/spinner.tsx new file mode 100644 index 000000000..483cdf73f --- /dev/null +++ b/surfsense_web/components/ui/spinner.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; + +interface SpinnerProps { + /** Size of the spinner */ + size?: "xs" | "sm" | "md" | "lg" | "xl"; + /** Whether to hide the track behind the spinner arc */ + hideTrack?: boolean; + /** Additional classes to apply */ + className?: string; +} + +const sizeClasses = { + xs: "h-3 w-3 border-[1.5px]", + sm: "h-4 w-4 border-2", + md: "h-6 w-6 border-2", + lg: "h-8 w-8 border-[3px]", + xl: "h-10 w-10 border-4", +}; + +export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) { + return ( +
+ ); +} + From 112f6ec4cced341811832c1ad79cc64944bfe146 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:47:39 +0530 Subject: [PATCH 24/53] refactor: remove archived functionality from notifications and related components - Removed the archived column from the Notification model and database schema, simplifying the notification structure. - Deleted ArchiveRequest and ArchiveResponse models, along with associated API endpoints for archiving notifications. - Updated InboxSidebar and related components to eliminate archiving functionality, streamlining the user experience. - Adjusted filtering logic in the InboxSidebar to focus solely on unread notifications, enhancing clarity and usability. --- surfsense_backend/app/db.py | 3 - .../app/routes/notifications_routes.py | 51 ----- .../layout/providers/LayoutDataProvider.tsx | 5 +- .../layout/ui/sidebar/InboxSidebar.tsx | 208 ++---------------- surfsense_web/components/ui/spinner.tsx | 5 +- surfsense_web/contracts/types/inbox.types.ts | 1 - surfsense_web/hooks/use-inbox.ts | 25 --- surfsense_web/lib/electric/client.ts | 5 +- 8 files changed, 24 insertions(+), 279 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index b969f9e55..38e27ecf2 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -784,9 +784,6 @@ class Notification(BaseModel, TimestampMixin): read = Column( Boolean, nullable=False, default=False, server_default=text("false"), index=True ) - archived = Column( - Boolean, nullable=False, default=False, server_default=text("false"), index=True - ) notification_metadata = Column("metadata", JSONB, nullable=True, default={}) updated_at = Column( TIMESTAMP(timezone=True), diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index 3bf7a4880..deee748d8 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -30,19 +30,6 @@ class MarkAllReadResponse(BaseModel): updated_count: int -class ArchiveRequest(BaseModel): - """Request body for archive/unarchive operations.""" - - archived: bool - - -class ArchiveResponse(BaseModel): - """Response for archive operations.""" - - success: bool - message: str - - @router.patch("/{notification_id}/read", response_model=MarkReadResponse) async def mark_notification_as_read( notification_id: int, @@ -113,41 +100,3 @@ async def mark_all_notifications_as_read( message=f"Marked {updated_count} notification(s) as read", updated_count=updated_count, ) - - -@router.patch("/{notification_id}/archive", response_model=ArchiveResponse) -async def archive_notification( - notification_id: int, - request: ArchiveRequest, - user: User = Depends(current_active_user), - session: AsyncSession = Depends(get_async_session), -) -> ArchiveResponse: - """ - Archive or unarchive a notification. - - Electric SQL will automatically sync this change to all connected clients. - """ - # Verify the notification belongs to the user - result = await session.execute( - select(Notification).where( - Notification.id == notification_id, - Notification.user_id == user.id, - ) - ) - notification = result.scalar_one_or_none() - - if not notification: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Notification not found", - ) - - # Update the notification - notification.archived = request.archived - await session.commit() - - action = "archived" if request.archived else "unarchived" - return ArchiveResponse( - success=True, - message=f"Notification {action}", - ) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 53f33f27b..aa7a06c81 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { Inbox, LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -87,7 +87,7 @@ export function LayoutDataProvider({ // Inbox hook const userId = user?.id ? String(user.id) : null; - const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead, archiveItem } = useInbox( + const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead } = useInbox( userId, Number(searchSpaceId) || null, null @@ -551,7 +551,6 @@ export function LayoutDataProvider({ loading={inboxLoading} markAsRead={markAsRead} markAllAsRead={markAllAsRead} - archiveItem={archiveItem} /> {/* Create Search Space Dialog */} diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index f81417a45..9e9ed2d21 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -2,7 +2,6 @@ import { AlertCircle, - Archive, AtSign, BellDot, Check, @@ -12,7 +11,6 @@ import { Inbox, ListFilter, MoreHorizontal, - RotateCcw, Search, X, } from "lucide-react"; @@ -29,7 +27,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; @@ -59,7 +56,7 @@ function getInitials(name: string | null | undefined, email: string | null | und } type InboxTab = "mentions" | "status"; -type InboxFilter = "all" | "unread" | "archived"; +type InboxFilter = "all" | "unread"; interface InboxSidebarProps { open: boolean; @@ -69,7 +66,6 @@ interface InboxSidebarProps { loading: boolean; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; - archiveItem: (id: number, archived: boolean) => Promise; onCloseMobileSidebar?: () => void; } @@ -81,7 +77,6 @@ export function InboxSidebar({ loading, markAsRead, markAllAsRead, - archiveItem, onCloseMobileSidebar, }: InboxSidebarProps) { const t = useTranslations("sidebar"); @@ -91,10 +86,9 @@ export function InboxSidebar({ const [activeTab, setActiveTab] = useState("mentions"); const [activeFilter, setActiveFilter] = useState("all"); const [mounted, setMounted] = useState(false); - // Unified dropdown state: "filter" | "options" | number (item id) | null - const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | number | null>(null); + // Dropdown state for filter and options menus + const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | null>(null); const [markingAsReadId, setMarkingAsReadId] = useState(null); - const [archivingItemId, setArchivingItemId] = useState(null); useEffect(() => { setMounted(true); @@ -143,16 +137,8 @@ export function InboxSidebar({ let items = currentTabItems; // Apply filter - // Note: Use `item.archived === true` to handle undefined/null as false - if (activeFilter === "all") { - // "Unread & read" shows all non-archived items - items = items.filter((item) => item.archived !== true); - } else if (activeFilter === "unread") { - // "Unread" shows only unread non-archived items - items = items.filter((item) => !item.read && item.archived !== true); - } else if (activeFilter === "archived") { - // "Archived" shows only archived items (must be explicitly true) - items = items.filter((item) => item.archived === true); + if (activeFilter === "unread") { + items = items.filter((item) => !item.read); } // Apply search query @@ -168,24 +154,14 @@ export function InboxSidebar({ return items; }, [currentTabItems, activeFilter, searchQuery]); - // Count unread items per tab (filter-aware) + // Count unread items per tab const unreadMentionsCount = useMemo(() => { - if (activeFilter === "archived") { - // In archived view, show unread archived items - return mentionItems.filter((item) => !item.read && item.archived === true).length; - } - // For "all" and "unread" filters, show unread non-archived items - return mentionItems.filter((item) => !item.read && item.archived !== true).length; - }, [mentionItems, activeFilter]); + return mentionItems.filter((item) => !item.read).length; + }, [mentionItems]); const unreadStatusCount = useMemo(() => { - if (activeFilter === "archived") { - // In archived view, show unread archived items - return statusItems.filter((item) => !item.read && item.archived === true).length; - } - // For "all" and "unread" filters, show unread non-archived items - return statusItems.filter((item) => !item.read && item.archived !== true).length; - }, [statusItems, activeFilter]); + return statusItems.filter((item) => !item.read).length; + }, [statusItems]); const handleItemClick = useCallback( async (item: InboxItem) => { @@ -217,28 +193,10 @@ export function InboxSidebar({ [markAsRead, router, onOpenChange, onCloseMobileSidebar] ); - const handleMarkAsRead = useCallback( - async (itemId: number) => { - setMarkingAsReadId(itemId); - await markAsRead(itemId); - setMarkingAsReadId(null); - }, - [markAsRead] - ); - const handleMarkAllAsRead = useCallback(async () => { await markAllAsRead(); }, [markAllAsRead]); - const handleToggleArchive = useCallback( - async (itemId: number, currentlyArchived: boolean) => { - setArchivingItemId(itemId); - await archiveItem(itemId, !currentlyArchived); - setArchivingItemId(null); - }, - [archiveItem] - ); - const handleClearSearch = useCallback(() => { setSearchQuery(""); }, []); @@ -385,7 +343,7 @@ export function InboxSidebar({ > - {t("unread_and_read") || "Unread & read"} + {t("all") || "All"} {activeFilter === "all" && } @@ -399,16 +357,6 @@ export function InboxSidebar({ {activeFilter === "unread" && } - setActiveFilter("archived")} - className="flex items-center justify-between" - > - - - {t("archived") || "Archived"} - - {activeFilter === "archived" && } - {filteredItems.map((item) => { const isMarkingAsRead = markingAsReadId === item.id; - const isArchiving = archivingItemId === item.id; - const isBusy = isMarkingAsRead || isArchiving; - const isArchived = item.archived === true; return (
@@ -528,8 +473,8 @@ export function InboxSidebar({ - )} - -
@@ -592,93 +500,13 @@ export function InboxSidebar({ - {/* Time/dot and 3-dot button container - swap on hover (desktop only) */} -
- {/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */} -
- - {formatTime(item.created_at)} - - {!item.read && ( - - )} -
- - {/* 3-dot menu - hidden by default, visible on hover or when dropdown is open */} - - setOpenDropdown(isOpen ? item.id : null) - } - > - - - - - {!item.read && ( - <> - handleMarkAsRead(item.id)} - disabled={isBusy} - > - - {t("mark_as_read") || "Mark as read"} - - - - )} - handleToggleArchive(item.id, isArchived)} - disabled={isArchiving} - > - {isArchived ? ( - <> - - {t("unarchive") || "Restore"} - - ) : ( - <> - - {t("archive") || "Archive"} - - )} - - - -
- - {/* Mobile time and unread dot - always visible on mobile */} -
+ {/* Time and unread dot - fixed width to prevent content shift */} +
{formatTime(item.created_at)} {!item.read && ( - + )}
diff --git a/surfsense_web/components/ui/spinner.tsx b/surfsense_web/components/ui/spinner.tsx index 483cdf73f..eeed30a8a 100644 --- a/surfsense_web/components/ui/spinner.tsx +++ b/surfsense_web/components/ui/spinner.tsx @@ -19,11 +19,10 @@ const sizeClasses = { export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) { return ( -
{ - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/archive`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ archived }), - } - ); - - if (!response.ok) { - const error = await response.json().catch(() => ({ detail: "Failed to update archive status" })); - throw new Error(error.detail || "Failed to update inbox item archive status"); - } - - return true; - } catch (err) { - console.error("Failed to update inbox item archive status:", err); - return false; - } - }, []); - return { inboxItems, unreadCount: totalUnreadCount, markAsRead, markAllAsRead, - archiveItem, loading, error, }; diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 222553f32..52f46d96d 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -55,7 +55,8 @@ const pendingSyncs = new Map>(); // Version for sync state - increment this to force fresh sync when Electric config changes // v2: user-specific database architecture // v3: added archived column to notifications -const SYNC_VERSION = 3; +// v4: removed archived column from notifications +const SYNC_VERSION = 4; // Database name prefix for identifying SurfSense databases const DB_PREFIX = "surfsense-"; @@ -182,7 +183,6 @@ export async function initElectric(userId: string): Promise { title TEXT NOT NULL, message TEXT NOT NULL, read BOOLEAN NOT NULL DEFAULT FALSE, - archived BOOLEAN NOT NULL DEFAULT FALSE, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ @@ -190,7 +190,6 @@ export async function initElectric(userId: string): Promise { CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read); - CREATE INDEX IF NOT EXISTS idx_notifications_archived ON notifications(archived); `); // Create the search_source_connectors table schema in PGlite From 306a725b7c980419057a074f37d1300ae8fb42c6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:07:08 +0530 Subject: [PATCH 25/53] refactor: streamline InboxSidebar component and improve tooltip integration --- .../layout/ui/sidebar/InboxSidebar.tsx | 75 +++++++++---------- surfsense_web/messages/en.json | 5 +- surfsense_web/messages/zh.json | 5 +- 3 files changed, 40 insertions(+), 45 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 9e9ed2d21..ac7fb428e 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -10,7 +10,6 @@ import { History, Inbox, ListFilter, - MoreHorizontal, Search, X, } from "lucide-react"; @@ -86,8 +85,8 @@ export function InboxSidebar({ const [activeTab, setActiveTab] = useState("mentions"); const [activeFilter, setActiveFilter] = useState("all"); const [mounted, setMounted] = useState(false); - // Dropdown state for filter and options menus - const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | null>(null); + // Dropdown state for filter menu + const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); const [markingAsReadId, setMarkingAsReadId] = useState(null); useEffect(() => { @@ -323,16 +322,23 @@ export function InboxSidebar({ open={openDropdown === "filter"} onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)} > - - - + + + + + + + + {t("filter") || "Filter"} + + {t("filter") || "Filter"} @@ -359,30 +365,23 @@ export function InboxSidebar({ - setOpenDropdown(isOpen ? "options" : null)} - > - + + - - - - - {t("mark_all_read") || "Mark all as read"} - - - + + {t("mark_all_read") || "Mark all as read"} + + + + {t("mark_all_read") || "Mark all as read"} + +
@@ -422,11 +421,8 @@ export function InboxSidebar({ {t("mentions") || "Mentions"} - - {unreadMentionsCount || 0} + + {unreadMentionsCount} @@ -437,11 +433,8 @@ export function InboxSidebar({ {t("status") || "Status"} - - {unreadStatusCount || 0} + + {unreadStatusCount} @@ -462,7 +455,7 @@ export function InboxSidebar({
Date: Thu, 22 Jan 2026 02:13:35 +0530 Subject: [PATCH 26/53] feat: add connector filtering to InboxSidebar for enhanced item management - Introduced a connector filter in the InboxSidebar to allow users to filter status items by connector type. - Implemented state management for selected connectors and reset functionality when switching tabs. - Enhanced the filtering logic to accommodate connector selection alongside existing filters. - Added UI elements for displaying available connectors as filter options in the status tab. --- .../layout/ui/sidebar/InboxSidebar.tsx | 84 ++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index ac7fb428e..2b57e5e6e 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -32,7 +32,9 @@ import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { InboxItem } from "@/hooks/use-inbox"; +import type { ConnectorIndexingMetadata } from "@/contracts/types/inbox.types"; import { cn } from "@/lib/utils"; /** @@ -84,6 +86,7 @@ export function InboxSidebar({ const [searchQuery, setSearchQuery] = useState(""); const [activeTab, setActiveTab] = useState("mentions"); const [activeFilter, setActiveFilter] = useState("all"); + const [selectedConnector, setSelectedConnector] = useState(null); const [mounted, setMounted] = useState(false); // Dropdown state for filter menu const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); @@ -114,6 +117,13 @@ export function InboxSidebar({ }; }, [open]); + // Reset connector filter when switching away from status tab + useEffect(() => { + if (activeTab !== "status") { + setSelectedConnector(null); + } + }, [activeTab]); + // Split items by type const mentionItems = useMemo( () => inboxItems.filter((item) => item.type === "new_mention"), @@ -128,18 +138,48 @@ export function InboxSidebar({ [inboxItems] ); + // Get unique connectors from status items for filtering + const uniqueConnectors = useMemo(() => { + const connectorMap = new Map(); + + statusItems + .filter((item) => item.type === "connector_indexing") + .forEach((item) => { + const metadata = item.metadata as ConnectorIndexingMetadata; + if (metadata?.connector_type && !connectorMap.has(metadata.connector_type)) { + connectorMap.set(metadata.connector_type, { + type: metadata.connector_type, + name: metadata.connector_name || metadata.connector_type, + }); + } + }); + + return Array.from(connectorMap.values()); + }, [statusItems]); + // Get items for current tab const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems; - // Filter items based on filter type and search query + // Filter items based on filter type, connector filter, and search query const filteredItems = useMemo(() => { let items = currentTabItems; - // Apply filter + // Apply read/unread filter if (activeFilter === "unread") { items = items.filter((item) => !item.read); } + // Apply connector filter (only for status tab) + if (activeTab === "status" && selectedConnector) { + items = items.filter((item) => { + if (item.type === "connector_indexing") { + const metadata = item.metadata as ConnectorIndexingMetadata; + return metadata?.connector_type === selectedConnector; + } + return false; // Hide document_processing when a specific connector is selected + }); + } + // Apply search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); @@ -151,7 +191,7 @@ export function InboxSidebar({ } return items; - }, [currentTabItems, activeFilter, searchQuery]); + }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]); // Count unread items per tab const unreadMentionsCount = useMemo(() => { @@ -441,6 +481,44 @@ export function InboxSidebar({ + {/* Connector filter chips - only show in status tab when there are connectors */} + {activeTab === "status" && uniqueConnectors.length > 0 && ( +
+ {/* Left shadow indicator */} +
+ {/* Right shadow indicator */} +
+
+ + {uniqueConnectors.map((connector) => ( + + + + + + {connector.name} + + + ))} +
+
+ )} +
{loading ? (
From 57baeda7677ac75be7b072e4fce0fce0ecd92249 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 02:14:46 +0530 Subject: [PATCH 27/53] refactor: remove archived column migration for notifications --- ...73_add_archived_column_to_notifications.py | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py diff --git a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py deleted file mode 100644 index 99962e501..000000000 --- a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Add archived column to notifications table - -Revision ID: 73 -Revises: 72 - -Adds an archived boolean column to the notifications table to allow users -to archive inbox items without deleting them. - -NOTE: Electric SQL automatically picks up schema changes when REPLICA IDENTITY FULL -is set (which was done in migration 66). We re-affirm it here to ensure replication -continues to work after adding the new column. -""" - -from collections.abc import Sequence - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "73" -down_revision: str | None = "72" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Add archived column to notifications table.""" - # Add the archived column with a default value - op.execute( - """ - ALTER TABLE notifications - ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE; - """ - ) - - # Create index for archived column - op.execute( - "CREATE INDEX IF NOT EXISTS ix_notifications_archived ON notifications (archived);" - ) - - # Re-affirm REPLICA IDENTITY FULL for Electric SQL after schema change - # This ensures Electric SQL continues to replicate all columns including the new one - op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;") - - -def downgrade() -> None: - """Remove archived column from notifications table.""" - op.execute("DROP INDEX IF EXISTS ix_notifications_archived;") - op.execute("ALTER TABLE notifications DROP COLUMN IF EXISTS archived;") - # Re-affirm REPLICA IDENTITY FULL after removing the column - op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;") - From 596515f5236cada751dd1d37736369a05250ec96 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 04:02:32 +0530 Subject: [PATCH 28/53] feat: integrate Drawer component for mobile filtering in InboxSidebar - Added a Drawer component to replace the dropdown menu for filtering in the InboxSidebar on mobile devices, enhancing usability. - Implemented state management for the filter drawer, allowing users to select filters and connectors seamlessly. - Updated the UI to accommodate the new Drawer, ensuring a consistent experience across different screen sizes. - Enhanced connector filtering logic to improve item management and display options effectively. --- .../comment-panel/comment-panel.tsx | 4 +- .../comment-sheet/comment-sheet.tsx | 40 ++- .../layout/ui/sidebar/InboxSidebar.tsx | 333 +++++++++++++----- surfsense_web/components/ui/drawer.tsx | 140 ++++++++ surfsense_web/components/ui/sheet.tsx | 4 +- surfsense_web/messages/en.json | 4 +- surfsense_web/messages/zh.json | 4 +- surfsense_web/package.json | 1 + surfsense_web/pnpm-lock.yaml | 18 + 9 files changed, 445 insertions(+), 103 deletions(-) create mode 100644 surfsense_web/components/ui/drawer.tsx diff --git a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx index 0def32932..c72c77f65 100644 --- a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx +++ b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx @@ -69,7 +69,7 @@ export function CommentPanel({ style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined} > {hasThreads && ( -
+
{threads.map((thread) => ( )} -
+
+ + + + + + Comments + {commentCount > 0 && ( + + {commentCount} + + )} + + +
+ +
+
+ + ); + } + + // Use Sheet for medium screens (right side) return ( - {/* Drag handle indicator - only for bottom sheet */} - {isBottomSheet && ( -
-
-
- )} - + Comments diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 2b57e5e6e..ebe537869 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -21,6 +21,13 @@ import { createPortal } from "react-dom"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, @@ -34,6 +41,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { InboxItem } from "@/hooks/use-inbox"; +import { useMediaQuery } from "@/hooks/use-media-query"; import type { ConnectorIndexingMetadata } from "@/contracts/types/inbox.types"; import { cn } from "@/lib/utils"; @@ -56,6 +64,40 @@ function getInitials(name: string | null | undefined, email: string | null | und return "U"; } +/** + * Get display name for connector type + */ +function getConnectorTypeDisplayName(connectorType: string): string { + const displayNames: Record = { + GITHUB_CONNECTOR: "GitHub", + GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", + GOOGLE_GMAIL_CONNECTOR: "Gmail", + GOOGLE_DRIVE_CONNECTOR: "Google Drive", + LINEAR_CONNECTOR: "Linear", + NOTION_CONNECTOR: "Notion", + SLACK_CONNECTOR: "Slack", + TEAMS_CONNECTOR: "Microsoft Teams", + DISCORD_CONNECTOR: "Discord", + JIRA_CONNECTOR: "Jira", + CONFLUENCE_CONNECTOR: "Confluence", + BOOKSTACK_CONNECTOR: "BookStack", + CLICKUP_CONNECTOR: "ClickUp", + AIRTABLE_CONNECTOR: "Airtable", + LUMA_CONNECTOR: "Luma", + ELASTICSEARCH_CONNECTOR: "Elasticsearch", + WEBCRAWLER_CONNECTOR: "Web Crawler", + YOUTUBE_CONNECTOR: "YouTube", + CIRCLEBACK_CONNECTOR: "Circleback", + MCP_CONNECTOR: "MCP", + TAVILY_API: "Tavily", + SEARXNG_API: "SearXNG", + LINKUP_API: "Linkup", + BAIDU_SEARCH_API: "Baidu", + }; + + return displayNames[connectorType] || connectorType.replace(/_/g, " ").replace(/CONNECTOR|API/gi, "").trim(); +} + type InboxTab = "mentions" | "status"; type InboxFilter = "all" | "unread"; @@ -82,14 +124,17 @@ export function InboxSidebar({ }: InboxSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const isMobile = !useMediaQuery("(min-width: 640px)"); const [searchQuery, setSearchQuery] = useState(""); const [activeTab, setActiveTab] = useState("mentions"); const [activeFilter, setActiveFilter] = useState("all"); const [selectedConnector, setSelectedConnector] = useState(null); const [mounted, setMounted] = useState(false); - // Dropdown state for filter menu + // Dropdown state for filter menu (desktop only) const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); + // Drawer state for filter menu (mobile only) + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); useEffect(() => { @@ -138,23 +183,23 @@ export function InboxSidebar({ [inboxItems] ); - // Get unique connectors from status items for filtering - const uniqueConnectors = useMemo(() => { - const connectorMap = new Map(); + // Get unique connector types from status items for filtering + const uniqueConnectorTypes = useMemo(() => { + const connectorTypes = new Set(); statusItems .filter((item) => item.type === "connector_indexing") .forEach((item) => { const metadata = item.metadata as ConnectorIndexingMetadata; - if (metadata?.connector_type && !connectorMap.has(metadata.connector_type)) { - connectorMap.set(metadata.connector_type, { - type: metadata.connector_type, - name: metadata.connector_name || metadata.connector_type, - }); + if (metadata?.connector_type) { + connectorTypes.add(metadata.connector_type); } }); - return Array.from(connectorMap.values()); + return Array.from(connectorTypes).map((type) => ({ + type, + displayName: getConnectorTypeDisplayName(type), + })); }, [statusItems]); // Get items for current tab @@ -358,53 +403,205 @@ export function InboxSidebar({

{t("inbox") || "Inbox"}

- setOpenDropdown(isOpen ? "filter" : null)} - > - - - + {/* Mobile: Button that opens bottom drawer */} + {isMobile ? ( + <> + + - - - - {t("filter") || "Filter"} - - - - - {t("filter") || "Filter"} - - setActiveFilter("all")} - className="flex items-center justify-between" - > - - - {t("all") || "All"} - - {activeFilter === "all" && } - - setActiveFilter("unread")} - className="flex items-center justify-between" - > - - - {t("unread") || "Unread"} - - {activeFilter === "unread" && } - - - + + + {t("filter") || "Filter"} + + + + + + + + + {t("filter") || "Filter"} + + +
+ {/* Filter section */} +
+

+ {t("filter") || "Filter"} +

+
+ + +
+
+ {/* Connectors section - only for status tab */} + {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( +
+

+ {t("connectors") || "Connectors"} +

+
+ + {uniqueConnectorTypes.map((connector) => ( + + ))} +
+
+ )} +
+
+
+ + ) : ( + /* Desktop: Dropdown menu */ + setOpenDropdown(isOpen ? "filter" : null)} + > + + + + + + + + {t("filter") || "Filter"} + + + + + {t("filter") || "Filter"} + + setActiveFilter("all")} + className="flex items-center justify-between" + > + + + {t("all") || "All"} + + {activeFilter === "all" && } + + setActiveFilter("unread")} + className="flex items-center justify-between" + > + + + {t("unread") || "Unread"} + + {activeFilter === "unread" && } + + {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( + <> + + {t("connectors") || "Connectors"} + + setSelectedConnector(null)} + className="flex items-center justify-between" + > + {t("all_connectors") || "All connectors"} + {selectedConnector === null && } + + {uniqueConnectorTypes.map((connector) => ( + setSelectedConnector(connector.type)} + className="flex items-center justify-between" + > + + {getConnectorIcon(connector.type, "h-4 w-4")} + {connector.displayName} + + {selectedConnector === connector.type && } + + ))} + + )} + + + )} - {uniqueConnectors.map((connector) => ( - - - - - - {connector.name} - - - ))} -
-
- )} -
{loading ? (
diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx new file mode 100644 index 000000000..81733487d --- /dev/null +++ b/surfsense_web/components/ui/drawer.tsx @@ -0,0 +1,140 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils"; + +function Drawer({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +function DrawerContent({ + className, + children, + overlayClassName, + ...props +}: React.ComponentProps & { + overlayClassName?: string; +}) { + return ( + + + + {children} + + + ); +} +DrawerContent.displayName = "DrawerContent"; + +function DrawerHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} +DrawerHeader.displayName = "DrawerHeader"; + +function DrawerFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} +DrawerFooter.displayName = "DrawerFooter"; + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +function DrawerHandle({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} +DrawerHandle.displayName = "DrawerHandle"; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, + DrawerHandle, +}; + diff --git a/surfsense_web/components/ui/sheet.tsx b/surfsense_web/components/ui/sheet.tsx index accd4f782..650e85403 100644 --- a/surfsense_web/components/ui/sheet.tsx +++ b/surfsense_web/components/ui/sheet.tsx @@ -42,13 +42,15 @@ function SheetContent({ className, children, side = "right", + overlayClassName, ...props }: React.ComponentProps & { side?: "top" | "right" | "bottom" | "left"; + overlayClassName?: string; }) { return ( - + Date: Thu, 22 Jan 2026 04:27:34 +0530 Subject: [PATCH 29/53] fix: always show the 3-dot and the folder in mobile view --- surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx | 4 ++-- surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 7f5ede04c..5dd9c2cfa 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem {/* Actions dropdown */} -
+
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index 52d681199..0ceafc113 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -37,7 +37,7 @@ export function SidebarSection({ {/* Action button - visible on hover (always visible on mobile) */} {action && ( -
+
{action}
)} From 36f1d28632ebc0131b788c2d003eae0649eac36d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:27:45 +0530 Subject: [PATCH 30/53] feat: implement infinite scroll and pagination for inbox items - Enhanced the inbox functionality by adding infinite scroll support in the InboxSidebar, allowing users to load more items seamlessly as they scroll. - Updated the useInbox hook to manage pagination, including loading states and item counts, improving performance with large datasets. - Introduced new props in InboxSidebar for loading more items, handling loading states, and indicating if more items are available. - Refactored the LayoutDataProvider to accommodate the new inbox loading logic, ensuring a smooth user experience. --- .../layout/providers/LayoutDataProvider.tsx | 14 ++- .../layout/ui/sidebar/InboxSidebar.tsx | 46 +++++++++- surfsense_web/hooks/use-inbox.ts | 86 +++++++++++++++++-- surfsense_web/lib/electric/client.ts | 4 +- 4 files changed, 137 insertions(+), 13 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index aa7a06c81..16e6da4cc 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -87,7 +87,16 @@ export function LayoutDataProvider({ // Inbox hook const userId = user?.id ? String(user.id) : null; - const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead } = useInbox( + const { + inboxItems, + unreadCount, + loading: inboxLoading, + loadingMore: inboxLoadingMore, + hasMore: inboxHasMore, + loadMore: inboxLoadMore, + markAsRead, + markAllAsRead + } = useInbox( userId, Number(searchSpaceId) || null, null @@ -549,6 +558,9 @@ export function LayoutDataProvider({ inboxItems={inboxItems} unreadCount={unreadCount} loading={inboxLoading} + loadingMore={inboxLoadingMore} + hasMore={inboxHasMore} + loadMore={inboxLoadMore} markAsRead={markAsRead} markAllAsRead={markAllAsRead} /> diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index ebe537869..a0a0d1f5e 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -16,7 +16,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -107,6 +107,9 @@ interface InboxSidebarProps { inboxItems: InboxItem[]; unreadCount: number; loading: boolean; + loadingMore?: boolean; + hasMore?: boolean; + loadMore?: () => void; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; @@ -118,6 +121,9 @@ export function InboxSidebar({ inboxItems, unreadCount, loading, + loadingMore = false, + hasMore = false, + loadMore, markAsRead, markAllAsRead, onCloseMobileSidebar, @@ -136,6 +142,9 @@ export function InboxSidebar({ // Drawer state for filter menu (mobile only) const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); + + // Prefetch trigger ref - placed on item near the end + const prefetchTriggerRef = useRef(null); useEffect(() => { setMounted(true); @@ -238,6 +247,32 @@ export function InboxSidebar({ return items; }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]); + // Intersection Observer for infinite scroll with prefetching + // Only active when not searching (search results are client-side filtered) + useEffect(() => { + if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return; + + const observer = new IntersectionObserver( + (entries) => { + // When trigger element is visible, load more + if (entries[0]?.isIntersecting) { + loadMore(); + } + }, + { + root: null, // viewport + rootMargin: "100px", // Start loading 100px before visible + threshold: 0, + } + ); + + if (prefetchTriggerRef.current) { + observer.observe(prefetchTriggerRef.current); + } + + return () => observer.disconnect(); + }, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]); + // Count unread items per tab const unreadMentionsCount = useMemo(() => { return mentionItems.filter((item) => !item.read).length; @@ -685,12 +720,15 @@ export function InboxSidebar({
) : filteredItems.length > 0 ? (
- {filteredItems.map((item) => { + {filteredItems.map((item, index) => { const isMarkingAsRead = markingAsReadId === item.id; + // Place prefetch trigger on 5th item from end (only if not searching) + const isPrefetchTrigger = !searchQuery && hasMore && index === filteredItems.length - 5; return (
); })} + {/* Fallback trigger at the very end if less than 5 items and not searching */} + {!searchQuery && filteredItems.length < 5 && hasMore && ( +
+ )}
) : searchQuery ? (
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index aa3de559d..c1541f71c 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -8,6 +8,8 @@ import { useElectricClient } from "@/lib/electric/context"; export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; +const PAGE_SIZE = 50; // Items per batch + /** * Hook for managing inbox items with Electric SQL real-time sync * @@ -17,6 +19,7 @@ export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types * Architecture: * - User-level sync: Syncs ALL inbox items for a user (runs once per user) * - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change) + * - Pagination: Loads items in batches for better performance with large datasets * * This separation ensures smooth transitions when switching search spaces (no flash). * @@ -35,10 +38,13 @@ export function useInbox( const [inboxItems, setInboxItems] = useState([]); const [totalUnreadCount, setTotalUnreadCount] = useState(0); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); + const offsetRef = useRef(0); // Track user-level sync key to prevent duplicate sync subscriptions const userSyncKeyRef = useRef(null); @@ -118,6 +124,13 @@ export function useInbox( }; }, [userId, electricClient]); + // Reset pagination when filters change + useEffect(() => { + offsetRef.current = 0; + setHasMore(true); + setInboxItems([]); + }, [userId, searchSpaceId, typeFilter]); + // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes // This runs independently of sync, allowing smooth transitions between search spaces useEffect(() => { @@ -144,24 +157,28 @@ export function useInbox( typeFilter ); - // Build query with optional type filter + // Build query with optional type filter and LIMIT for pagination // Note: Backend table is still named "notifications" const baseQuery = `SELECT * FROM notifications WHERE user_id = $1 AND (search_space_id = $2 OR search_space_id IS NULL)`; const typeClause = typeFilter ? ` AND type = $3` : ""; const orderClause = ` ORDER BY created_at DESC`; - const fullQuery = baseQuery + typeClause + orderClause; + const limitClause = ` LIMIT ${PAGE_SIZE}`; + const fullQuery = baseQuery + typeClause + orderClause + limitClause; const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; // Fetch inbox items for current search space immediately const result = await client.db.query(fullQuery, params); if (mounted) { - setInboxItems(result.rows || []); + const items = result.rows || []; + setInboxItems(items); + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; } - // Set up live query for real-time updates + // Set up live query for real-time updates (first page only) const db = client.db as any; if (db.live?.query && typeof db.live.query === "function") { @@ -174,16 +191,36 @@ export function useInbox( // Set initial results from live query if (liveQuery.initialResults?.rows) { - setInboxItems(liveQuery.initialResults.rows); + const items = liveQuery.initialResults.rows; + setInboxItems(items); + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; } else if (liveQuery.rows) { - setInboxItems(liveQuery.rows); + const items = liveQuery.rows; + setInboxItems(items); + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; } // Subscribe to changes if (typeof liveQuery.subscribe === "function") { liveQuery.subscribe((result: { rows: InboxItem[] }) => { if (mounted && result.rows) { - setInboxItems(result.rows); + // Only update first page from live query + // Keep any additionally loaded items + setInboxItems(prev => { + if (prev.length <= PAGE_SIZE) { + const items = result.rows; + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; + return items; + } + // Merge: new first page + existing extra items + const newFirstPage = result.rows; + const existingExtra = prev.slice(PAGE_SIZE); + offsetRef.current = newFirstPage.length + existingExtra.length; + return [...newFirstPage, ...existingExtra]; + }); } }); } @@ -290,6 +327,38 @@ export function useInbox( }; }, [userId, searchSpaceId, electricClient]); + // Load more items (for infinite scroll) + const loadMore = useCallback(async () => { + if (!userId || !electricClient || loadingMore || !hasMore) { + return; + } + + setLoadingMore(true); + const client = electricClient; + + try { + const baseQuery = `SELECT * FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL)`; + const typeClause = typeFilter ? ` AND type = $3` : ""; + const orderClause = ` ORDER BY created_at DESC`; + const limitOffsetClause = ` LIMIT ${PAGE_SIZE} OFFSET ${offsetRef.current}`; + const fullQuery = baseQuery + typeClause + orderClause + limitOffsetClause; + const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; + + const result = await client.db.query(fullQuery, params); + const newItems = result.rows || []; + + setInboxItems(prev => [...prev, ...newItems]); + setHasMore(newItems.length === PAGE_SIZE); + offsetRef.current += newItems.length; + } catch (err) { + console.error("[useInbox] Failed to load more:", err); + } finally { + setLoadingMore(false); + } + }, [userId, searchSpaceId, typeFilter, electricClient, loadingMore, hasMore]); + // Mark inbox item as read via backend API const markAsRead = useCallback(async (itemId: number) => { try { @@ -338,6 +407,9 @@ export function useInbox( markAsRead, markAllAsRead, loading, + loadingMore, + hasMore, + loadMore, error, }; } diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 52f46d96d..7f7eb7552 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -54,9 +54,7 @@ const pendingSyncs = new Map>(); // Version for sync state - increment this to force fresh sync when Electric config changes // v2: user-specific database architecture -// v3: added archived column to notifications -// v4: removed archived column from notifications -const SYNC_VERSION = 4; +const SYNC_VERSION = 2; // Database name prefix for identifying SurfSense databases const DB_PREFIX = "surfsense-"; From a449e7e2a651d2964fcd5480b721b0d01fc73832 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:02:25 +0530 Subject: [PATCH 31/53] feat: enhance notifications API and inbox functionality - Added a new endpoint to list notifications with pagination, allowing users to fetch older notifications beyond the sync window. - Introduced response models for notifications and improved error handling for date filtering. - Updated the useInbox hook to support API fallback for loading older notifications when Electric SQL returns no recent items. - Implemented deduplication and sorting logic for inbox items to prevent race conditions and ensure consistent data display. - Enhanced loading logic for inbox items, including improved pagination and handling of loading states. --- .../app/routes/notifications_routes.py | 129 +++++- surfsense_web/hooks/use-inbox.ts | 414 ++++++++++-------- surfsense_web/lib/electric/client.ts | 3 +- 3 files changed, 354 insertions(+), 192 deletions(-) diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index deee748d8..f7b52f3e7 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -1,12 +1,16 @@ """ Notifications API routes. -These endpoints allow marking notifications as read. -Electric SQL automatically syncs the changes to all connected clients. +These endpoints allow marking notifications as read and fetching older notifications. +Electric SQL automatically syncs the changes to all connected clients for recent items. +For older items (beyond the sync window), use the list endpoint. """ -from fastapi import APIRouter, Depends, HTTPException, status +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel -from sqlalchemy import select, update +from sqlalchemy import desc, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db import Notification, User, get_async_session @@ -15,6 +19,33 @@ from app.users import current_active_user router = APIRouter(prefix="/notifications", tags=["notifications"]) +class NotificationResponse(BaseModel): + """Response model for a single notification.""" + + id: int + user_id: str + search_space_id: Optional[int] + type: str + title: str + message: str + read: bool + metadata: dict + created_at: str + updated_at: Optional[str] + + class Config: + from_attributes = True + + +class NotificationListResponse(BaseModel): + """Response for listing notifications with pagination.""" + + items: list[NotificationResponse] + total: int + has_more: bool + next_offset: Optional[int] + + class MarkReadResponse(BaseModel): """Response for mark as read operations.""" @@ -30,6 +61,96 @@ class MarkAllReadResponse(BaseModel): updated_count: int +@router.get("", response_model=NotificationListResponse) +async def list_notifications( + search_space_id: Optional[int] = Query(None, description="Filter by search space ID"), + type_filter: Optional[str] = Query(None, alias="type", description="Filter by notification type"), + before_date: Optional[str] = Query(None, description="Get notifications before this ISO date (for pagination)"), + limit: int = Query(50, ge=1, le=100, description="Number of items to return"), + offset: int = Query(0, ge=0, description="Number of items to skip"), + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> NotificationListResponse: + """ + List notifications for the current user with pagination. + + This endpoint is used as a fallback for older notifications that are + outside the Electric SQL sync window (2 weeks). + + Use `before_date` to paginate through older notifications efficiently. + """ + # Build base query + query = select(Notification).where(Notification.user_id == user.id) + count_query = select(func.count(Notification.id)).where(Notification.user_id == user.id) + + # Filter by search space (include null search_space_id for global notifications) + if search_space_id is not None: + query = query.where( + (Notification.search_space_id == search_space_id) | + (Notification.search_space_id.is_(None)) + ) + count_query = count_query.where( + (Notification.search_space_id == search_space_id) | + (Notification.search_space_id.is_(None)) + ) + + # Filter by type + if type_filter: + query = query.where(Notification.type == type_filter) + count_query = count_query.where(Notification.type == type_filter) + + # Filter by date (for efficient pagination of older items) + if before_date: + try: + before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00")) + query = query.where(Notification.created_at < before_datetime) + count_query = count_query.where(Notification.created_at < before_datetime) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)", + ) from None + + # Get total count + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Apply ordering and pagination + query = query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1) + + # Execute query + result = await session.execute(query) + notifications = result.scalars().all() + + # Check if there are more items + has_more = len(notifications) > limit + if has_more: + notifications = notifications[:limit] + + # Convert to response format + items = [] + for notification in notifications: + items.append(NotificationResponse( + id=notification.id, + user_id=str(notification.user_id), + search_space_id=notification.search_space_id, + type=notification.type, + title=notification.title, + message=notification.message, + read=notification.read, + metadata=notification.notification_metadata or {}, + created_at=notification.created_at.isoformat() if notification.created_at else "", + updated_at=notification.updated_at.isoformat() if notification.updated_at else None, + )) + + return NotificationListResponse( + items=items, + total=total, + has_more=has_more, + next_offset=offset + limit if has_more else None, + ) + + @router.patch("/{notification_id}/read", response_model=MarkReadResponse) async def mark_notification_as_read( notification_id: int, diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index c1541f71c..bd4a6ee35 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -8,31 +8,74 @@ import { useElectricClient } from "@/lib/electric/context"; export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; -const PAGE_SIZE = 50; // Items per batch +const PAGE_SIZE = 50; +const SYNC_WINDOW_DAYS = 14; /** - * Hook for managing inbox items with Electric SQL real-time sync + * Deduplicate by ID and sort by created_at descending. + * This is the SINGLE source of truth for deduplication - prevents race conditions. + */ +function deduplicateAndSort(items: InboxItem[]): InboxItem[] { + const seen = new Map(); + for (const item of items) { + if (!seen.has(item.id)) { + seen.set(item.id, item); + } + } + return Array.from(seen.values()).sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); +} + +/** + * Calculate the cutoff date for sync window + */ +function getSyncCutoffDate(): string { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS); + return cutoff.toISOString(); +} + +/** + * Convert a date value to ISO string format + */ +function toISOString(date: string | Date | null | undefined): string | null { + if (!date) return null; + if (date instanceof Date) return date.toISOString(); + if (typeof date === "string") { + if (date.includes("T")) return date; + try { + return new Date(date).toISOString(); + } catch { + return date; + } + } + return null; +} + +/** + * Hook for managing inbox items with Electric SQL real-time sync + API fallback * - * Uses the Electric client from context (provided by ElectricProvider) - * instead of initializing its own - prevents race conditions and memory leaks + * Architecture (Simplified & Race-Condition Free): + * - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates + * - Live Query: Provides reactive first page from PGLite + * - API: Handles all pagination (more reliable than mixing with Electric) * - * Architecture: - * - User-level sync: Syncs ALL inbox items for a user (runs once per user) - * - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change) - * - Pagination: Loads items in batches for better performance with large datasets - * - * This separation ensures smooth transitions when switching search spaces (no flash). + * Key Design Decisions: + * 1. No mutable refs for cursor - cursor computed from current state + * 2. Single deduplicateAndSort function - prevents inconsistencies + * 3. Filter-based preservation in live query - prevents data loss + * 4. Auto-fetch from API when Electric returns 0 items * * @param userId - The user ID to fetch inbox items for - * @param searchSpaceId - The search space ID to filter inbox items (null shows global items only) - * @param typeFilter - Optional inbox item type to filter by (null shows all types) + * @param searchSpaceId - The search space ID to filter inbox items + * @param typeFilter - Optional inbox item type to filter by */ export function useInbox( userId: string | null, searchSpaceId: number | null, typeFilter: InboxItemTypeEnum | null = null ) { - // Get Electric client from context - ElectricProvider handles initialization const electricClient = useElectricClient(); const [inboxItems, setInboxItems] = useState([]); @@ -41,58 +84,51 @@ export function useInbox( const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); + const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - const offsetRef = useRef(0); - - // Track user-level sync key to prevent duplicate sync subscriptions const userSyncKeyRef = useRef(null); - // EFFECT 1: User-level sync - runs once per user, syncs ALL inbox items + // EFFECT 1: Electric SQL sync for real-time updates useEffect(() => { if (!userId || !electricClient) { setLoading(!electricClient); return; } - const userSyncKey = `inbox_${userId}`; - if (userSyncKeyRef.current === userSyncKey) { - // Already syncing for this user - return; - } - - // Capture electricClient to satisfy TypeScript in async function const client = electricClient; let mounted = true; - userSyncKeyRef.current = userSyncKey; - async function startUserSync() { + async function startSync() { try { - console.log("[useInbox] Starting user-level sync for:", userId); + const cutoffDate = getSyncCutoffDate(); + const userSyncKey = `inbox_${userId}_${cutoffDate}`; + + // Skip if already syncing with this key + if (userSyncKeyRef.current === userSyncKey) return; + + // Clean up previous sync + if (syncHandleRef.current) { + syncHandleRef.current.unsubscribe(); + syncHandleRef.current = null; + } + + console.log("[useInbox] Starting sync for:", userId); + userSyncKeyRef.current = userSyncKey; - // Sync ALL inbox items for this user (cached via syncShape caching) - // Note: Backend table is still named "notifications" const handle = await client.syncShape({ table: "notifications", - where: `user_id = '${userId}'`, + where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`, primaryKey: ["id"], }); - console.log("[useInbox] User sync started:", { - isUpToDate: handle.isUpToDate, - }); - // Wait for initial sync with timeout if (!handle.isUpToDate && handle.initialSyncPromise) { - try { - await Promise.race([ - handle.initialSyncPromise, - new Promise((resolve) => setTimeout(resolve, 2000)), - ]); - } catch (syncErr) { - console.error("[useInbox] Initial sync failed:", syncErr); - } + await Promise.race([ + handle.initialSyncPromise, + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); } if (!mounted) { @@ -105,18 +141,17 @@ export function useInbox( setError(null); } catch (err) { if (!mounted) return; - console.error("[useInbox] Failed to start user sync:", err); - setError(err instanceof Error ? err : new Error("Failed to sync inbox")); + console.error("[useInbox] Sync failed:", err); + setError(err instanceof Error ? err : new Error("Sync failed")); setLoading(false); } } - startUserSync(); + startSync(); return () => { mounted = false; userSyncKeyRef.current = null; - if (syncHandleRef.current) { syncHandleRef.current.unsubscribe(); syncHandleRef.current = null; @@ -124,117 +159,126 @@ export function useInbox( }; }, [userId, electricClient]); - // Reset pagination when filters change + // Reset when filters change useEffect(() => { - offsetRef.current = 0; setHasMore(true); setInboxItems([]); }, [userId, searchSpaceId, typeFilter]); - // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes - // This runs independently of sync, allowing smooth transitions between search spaces + // EFFECT 2: Live query for real-time updates + auto-fetch from API if empty useEffect(() => { - if (!userId || !electricClient) { - return; - } + if (!userId || !electricClient) return; - // Capture electricClient to satisfy TypeScript in async function const client = electricClient; let mounted = true; - async function updateQuery() { - // Clean up previous live query (but DON'T clear inbox items - keep showing old until new arrive) + async function setupLiveQuery() { + // Clean up previous live query if (liveQueryRef.current) { liveQueryRef.current.unsubscribe(); liveQueryRef.current = null; } try { - console.log( - "[useInbox] Updating query for searchSpace:", - searchSpaceId, - "typeFilter:", - typeFilter - ); + const cutoff = getSyncCutoffDate(); - // Build query with optional type filter and LIMIT for pagination - // Note: Backend table is still named "notifications" - const baseQuery = `SELECT * FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL)`; - const typeClause = typeFilter ? ` AND type = $3` : ""; - const orderClause = ` ORDER BY created_at DESC`; - const limitClause = ` LIMIT ${PAGE_SIZE}`; - const fullQuery = baseQuery + typeClause + orderClause + limitClause; - const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; + const query = `SELECT * FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL) + AND created_at > '${cutoff}' + ${typeFilter ? "AND type = $3" : ""} + ORDER BY created_at DESC + LIMIT ${PAGE_SIZE}`; - // Fetch inbox items for current search space immediately - const result = await client.db.query(fullQuery, params); + const params = typeFilter + ? [userId, searchSpaceId, typeFilter] + : [userId, searchSpaceId]; - if (mounted) { - const items = result.rows || []; - setInboxItems(items); - setHasMore(items.length === PAGE_SIZE); - offsetRef.current = items.length; - } - - // Set up live query for real-time updates (first page only) const db = client.db as any; - if (db.live?.query && typeof db.live.query === "function") { - const liveQuery = await db.live.query(fullQuery, params); + // Initial fetch from PGLite + const result = await client.db.query(query, params); + + if (mounted && result.rows) { + const items = deduplicateAndSort(result.rows); + setInboxItems(items); + + // AUTO-FETCH: If Electric returned 0 items, check API for older items + // This handles the edge case where user has no recent notifications + // but has older ones outside the sync window + if (items.length === 0) { + console.log( + "[useInbox] Electric returned 0 items, checking API for older notifications" + ); + try { + const apiParams = new URLSearchParams(); + if (searchSpaceId !== null) { + apiParams.append("search_space_id", String(searchSpaceId)); + } + if (typeFilter) { + apiParams.append("type", typeFilter); + } + apiParams.append("limit", String(PAGE_SIZE)); + + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${apiParams.toString()}` + ); + + if (response.ok && mounted) { + const data = await response.json(); + const apiItems: InboxItem[] = data.items.map((item: any) => ({ + ...item, + metadata: item.metadata || {}, + })); + + if (apiItems.length > 0) { + setInboxItems(apiItems); + } + setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE); + } + } catch (err) { + console.error("[useInbox] API fallback failed:", err); + } + } + } + + // Set up live query for real-time updates + if (db.live?.query) { + const liveQuery = await db.live.query(query, params); if (!mounted) { liveQuery.unsubscribe?.(); return; } - // Set initial results from live query - if (liveQuery.initialResults?.rows) { - const items = liveQuery.initialResults.rows; - setInboxItems(items); - setHasMore(items.length === PAGE_SIZE); - offsetRef.current = items.length; - } else if (liveQuery.rows) { - const items = liveQuery.rows; - setInboxItems(items); - setHasMore(items.length === PAGE_SIZE); - offsetRef.current = items.length; - } - - // Subscribe to changes - if (typeof liveQuery.subscribe === "function") { + if (liveQuery.subscribe) { liveQuery.subscribe((result: { rows: InboxItem[] }) => { if (mounted && result.rows) { - // Only update first page from live query - // Keep any additionally loaded items - setInboxItems(prev => { - if (prev.length <= PAGE_SIZE) { - const items = result.rows; - setHasMore(items.length === PAGE_SIZE); - offsetRef.current = items.length; - return items; - } - // Merge: new first page + existing extra items - const newFirstPage = result.rows; - const existingExtra = prev.slice(PAGE_SIZE); - offsetRef.current = newFirstPage.length + existingExtra.length; - return [...newFirstPage, ...existingExtra]; + setInboxItems((prev) => { + const liveItems = result.rows; + const liveItemIds = new Set(liveItems.map((item) => item.id)); + + // FIXED: Keep ALL items not in live result (not just slice) + // This prevents data loss when new notifications push items + // out of the LIMIT window + const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id)); + + return deduplicateAndSort([...liveItems, ...itemsToKeep]); }); } }); } - if (typeof liveQuery.unsubscribe === "function") { + if (liveQuery.unsubscribe) { liveQueryRef.current = liveQuery; } } } catch (err) { - console.error("[useInbox] Failed to update query:", err); + console.error("[useInbox] Live query error:", err); } } - updateQuery(); + setupLiveQuery(); return () => { mounted = false; @@ -245,61 +289,45 @@ export function useInbox( }; }, [userId, searchSpaceId, typeFilter, electricClient]); - // EFFECT 3: Total unread count - independent of type filter - // This ensures the badge count stays consistent regardless of active filter + // EFFECT 3: Unread count with live updates useEffect(() => { - if (!userId || !electricClient) { - return; - } + if (!userId || !electricClient) return; - // Capture electricClient to satisfy TypeScript in async function const client = electricClient; let mounted = true; async function updateUnreadCount() { - // Clean up previous live query if (unreadCountLiveQueryRef.current) { unreadCountLiveQueryRef.current.unsubscribe(); unreadCountLiveQueryRef.current = null; } try { - // Note: Backend table is still named "notifications" - const countQuery = `SELECT COUNT(*) as count FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL) - AND read = false`; + const cutoff = getSyncCutoffDate(); + const query = `SELECT COUNT(*) as count FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL) + AND read = false + AND created_at > '${cutoff}'`; - // Fetch initial count - const result = await client.db.query<{ count: number }>(countQuery, [ + const result = await client.db.query<{ count: number }>(query, [ userId, searchSpaceId, ]); - if (mounted && result.rows?.[0]) { setTotalUnreadCount(Number(result.rows[0].count) || 0); } - // Set up live query for real-time updates const db = client.db as any; - - if (db.live?.query && typeof db.live.query === "function") { - const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]); + if (db.live?.query) { + const liveQuery = await db.live.query(query, [userId, searchSpaceId]); if (!mounted) { liveQuery.unsubscribe?.(); return; } - // Set initial results from live query - if (liveQuery.initialResults?.rows?.[0]) { - setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0); - } else if (liveQuery.rows?.[0]) { - setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0); - } - - // Subscribe to changes - if (typeof liveQuery.subscribe === "function") { + if (liveQuery.subscribe) { liveQuery.subscribe((result: { rows: { count: number }[] }) => { if (mounted && result.rows?.[0]) { setTotalUnreadCount(Number(result.rows[0].count) || 0); @@ -307,12 +335,12 @@ export function useInbox( }); } - if (typeof liveQuery.unsubscribe === "function") { + if (liveQuery.unsubscribe) { unreadCountLiveQueryRef.current = liveQuery; } } } catch (err) { - console.error("[useInbox] Failed to update unread count:", err); + console.error("[useInbox] Unread count error:", err); } } @@ -327,76 +355,88 @@ export function useInbox( }; }, [userId, searchSpaceId, electricClient]); - // Load more items (for infinite scroll) + // loadMore - Pure cursor-based pagination, no race conditions + // Cursor is computed from current state, not stored in refs const loadMore = useCallback(async () => { - if (!userId || !electricClient || loadingMore || !hasMore) { - return; - } + // Removed inboxItems.length === 0 check to allow loading older items + // when Electric returns 0 items + if (!userId || loadingMore || !hasMore) return; setLoadingMore(true); - const client = electricClient; try { - const baseQuery = `SELECT * FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL)`; - const typeClause = typeFilter ? ` AND type = $3` : ""; - const orderClause = ` ORDER BY created_at DESC`; - const limitOffsetClause = ` LIMIT ${PAGE_SIZE} OFFSET ${offsetRef.current}`; - const fullQuery = baseQuery + typeClause + orderClause + limitOffsetClause; - const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; + // Cursor is computed from current state - no stale refs possible + const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null; + const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null; - const result = await client.db.query(fullQuery, params); - const newItems = result.rows || []; + const params = new URLSearchParams(); + if (searchSpaceId !== null) { + params.append("search_space_id", String(searchSpaceId)); + } + if (typeFilter) { + params.append("type", typeFilter); + } + // Only add before_date if we have a cursor + // Without before_date, API returns newest items first + if (beforeDate) { + params.append("before_date", beforeDate); + } + params.append("limit", String(PAGE_SIZE)); - setInboxItems(prev => [...prev, ...newItems]); - setHasMore(newItems.length === PAGE_SIZE); - offsetRef.current += newItems.length; + console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)"); + + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${params.toString()}` + ); + + if (!response.ok) { + throw new Error("Failed to fetch notifications"); + } + + const data = await response.json(); + const apiItems: InboxItem[] = data.items.map((item: any) => ({ + ...item, + metadata: item.metadata || {}, + })); + + if (apiItems.length > 0) { + // Functional update ensures we always merge with latest state + setInboxItems((prev) => deduplicateAndSort([...prev, ...apiItems])); + } + + // Use API's has_more flag if available, otherwise check count + setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE); } catch (err) { - console.error("[useInbox] Failed to load more:", err); + console.error("[useInbox] Load more failed:", err); } finally { setLoadingMore(false); } - }, [userId, searchSpaceId, typeFilter, electricClient, loadingMore, hasMore]); + }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); - // Mark inbox item as read via backend API + // Mark inbox item as read const markAsRead = useCallback(async (itemId: number) => { try { - // Note: Backend API endpoint is still /notifications/ const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`, { method: "PATCH" } ); - - if (!response.ok) { - const error = await response.json().catch(() => ({ detail: "Failed to mark as read" })); - throw new Error(error.detail || "Failed to mark inbox item as read"); - } - - return true; + return response.ok; } catch (err) { - console.error("Failed to mark inbox item as read:", err); + console.error("Failed to mark as read:", err); return false; } }, []); - // Mark all inbox items as read via backend API + // Mark all inbox items as read const markAllAsRead = useCallback(async () => { try { - // Note: Backend API endpoint is still /notifications/ const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, { method: "PATCH" } ); - - if (!response.ok) { - const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" })); - throw new Error(error.detail || "Failed to mark all inbox items as read"); - } - - return true; + return response.ok; } catch (err) { - console.error("Failed to mark all inbox items as read:", err); + console.error("Failed to mark all as read:", err); return false; } }, []); @@ -410,7 +450,7 @@ export function useInbox( loadingMore, hasMore, loadMore, + isUsingApiFallback: true, // Always use API for pagination error, }; } - diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 7f7eb7552..6a9d87b88 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -54,7 +54,8 @@ const pendingSyncs = new Map>(); // Version for sync state - increment this to force fresh sync when Electric config changes // v2: user-specific database architecture -const SYNC_VERSION = 2; +// v3: consistent cutoff date for sync+queries, visibility refresh support +const SYNC_VERSION = 3; // Database name prefix for identifying SurfSense databases const DB_PREFIX = "surfsense-"; From be7ba764179a01f3e9ff4bc5d18cd917918c0aac Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:07:06 +0530 Subject: [PATCH 32/53] chore: ran backend and frontend linting --- .../app/routes/notifications_routes.py | 87 +++++++++++-------- .../dashboard/[search_space_id]/team/page.tsx | 9 +- .../comment-sheet/comment-sheet.tsx | 8 +- .../layout/providers/LayoutDataProvider.tsx | 18 ++-- .../ui/sidebar/AllPrivateChatsSidebar.tsx | 8 +- .../ui/sidebar/AllSharedChatsSidebar.tsx | 8 +- .../components/shared/llm-config-form.tsx | 4 +- surfsense_web/components/ui/drawer.tsx | 47 +++------- surfsense_web/components/ui/spinner.tsx | 47 +++++----- surfsense_web/contracts/types/inbox.types.ts | 1 - surfsense_web/hooks/use-inbox.ts | 9 +- 11 files changed, 119 insertions(+), 127 deletions(-) diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index f7b52f3e7..6172aacc5 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -6,7 +6,6 @@ For older items (beyond the sync window), use the list endpoint. """ from datetime import datetime -from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel @@ -24,14 +23,14 @@ class NotificationResponse(BaseModel): id: int user_id: str - search_space_id: Optional[int] + search_space_id: int | None type: str title: str message: str read: bool metadata: dict created_at: str - updated_at: Optional[str] + updated_at: str | None class Config: from_attributes = True @@ -43,7 +42,7 @@ class NotificationListResponse(BaseModel): items: list[NotificationResponse] total: int has_more: bool - next_offset: Optional[int] + next_offset: int | None class MarkReadResponse(BaseModel): @@ -63,9 +62,15 @@ class MarkAllReadResponse(BaseModel): @router.get("", response_model=NotificationListResponse) async def list_notifications( - search_space_id: Optional[int] = Query(None, description="Filter by search space ID"), - type_filter: Optional[str] = Query(None, alias="type", description="Filter by notification type"), - before_date: Optional[str] = Query(None, description="Get notifications before this ISO date (for pagination)"), + search_space_id: int | None = Query( + None, description="Filter by search space ID" + ), + type_filter: str | None = Query( + None, alias="type", description="Filter by notification type" + ), + before_date: str | None = Query( + None, description="Get notifications before this ISO date (for pagination)" + ), limit: int = Query(50, ge=1, le=100, description="Number of items to return"), offset: int = Query(0, ge=0, description="Number of items to skip"), user: User = Depends(current_active_user), @@ -73,32 +78,34 @@ async def list_notifications( ) -> NotificationListResponse: """ List notifications for the current user with pagination. - + This endpoint is used as a fallback for older notifications that are outside the Electric SQL sync window (2 weeks). - + Use `before_date` to paginate through older notifications efficiently. """ # Build base query query = select(Notification).where(Notification.user_id == user.id) - count_query = select(func.count(Notification.id)).where(Notification.user_id == user.id) - + count_query = select(func.count(Notification.id)).where( + Notification.user_id == user.id + ) + # Filter by search space (include null search_space_id for global notifications) if search_space_id is not None: query = query.where( - (Notification.search_space_id == search_space_id) | - (Notification.search_space_id.is_(None)) + (Notification.search_space_id == search_space_id) + | (Notification.search_space_id.is_(None)) ) count_query = count_query.where( - (Notification.search_space_id == search_space_id) | - (Notification.search_space_id.is_(None)) + (Notification.search_space_id == search_space_id) + | (Notification.search_space_id.is_(None)) ) - + # Filter by type if type_filter: query = query.where(Notification.type == type_filter) count_query = count_query.where(Notification.type == type_filter) - + # Filter by date (for efficient pagination of older items) if before_date: try: @@ -110,39 +117,47 @@ async def list_notifications( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)", ) from None - + # Get total count total_result = await session.execute(count_query) total = total_result.scalar() or 0 - + # Apply ordering and pagination - query = query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1) - + query = ( + query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1) + ) + # Execute query result = await session.execute(query) notifications = result.scalars().all() - + # Check if there are more items has_more = len(notifications) > limit if has_more: notifications = notifications[:limit] - + # Convert to response format items = [] for notification in notifications: - items.append(NotificationResponse( - id=notification.id, - user_id=str(notification.user_id), - search_space_id=notification.search_space_id, - type=notification.type, - title=notification.title, - message=notification.message, - read=notification.read, - metadata=notification.notification_metadata or {}, - created_at=notification.created_at.isoformat() if notification.created_at else "", - updated_at=notification.updated_at.isoformat() if notification.updated_at else None, - )) - + items.append( + NotificationResponse( + id=notification.id, + user_id=str(notification.user_id), + search_space_id=notification.search_space_id, + type=notification.type, + title=notification.title, + message=notification.message, + read=notification.read, + metadata=notification.notification_metadata or {}, + created_at=notification.created_at.isoformat() + if notification.created_at + else "", + updated_at=notification.updated_at.isoformat() + if notification.updated_at + else None, + ) + ) + return NotificationListResponse( items=items, total=total, diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index a0bc6be03..b661e9222 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -711,12 +711,9 @@ function MembersTab({ ) : ( - - {member.role?.name || "No role"} - + + {member.role?.name || "No role"} + )} diff --git a/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx b/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx index b8e58ad2b..d483ab261 100644 --- a/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx +++ b/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx @@ -1,7 +1,13 @@ "use client"; import { MessageSquare } from "lucide-react"; -import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container"; diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 16e6da4cc..5f4617b84 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -87,20 +87,16 @@ export function LayoutDataProvider({ // Inbox hook const userId = user?.id ? String(user.id) : null; - const { - inboxItems, - unreadCount, - loading: inboxLoading, + const { + inboxItems, + unreadCount, + loading: inboxLoading, loadingMore: inboxLoadingMore, hasMore: inboxHasMore, loadMore: inboxLoadMore, - markAsRead, - markAllAsRead - } = useInbox( - userId, - Number(searchSpaceId) || null, - null - ); + markAsRead, + markAllAsRead, + } = useInbox(userId, Number(searchSpaceId) || null, null); // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 78bac3371..39f1b95bc 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -280,7 +280,9 @@ export function AllPrivateChatsSidebar({ Active - {activeCount} + + {activeCount} + Archived - {archivedCount} + + {archivedCount} + diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index e3b6174c3..8dd593945 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -280,7 +280,9 @@ export function AllSharedChatsSidebar({ Active - {activeCount} + + {activeCount} + Archived - {archivedCount} + + {archivedCount} + diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx index e2e45194b..5ffff1ab7 100644 --- a/surfsense_web/components/shared/llm-config-form.tsx +++ b/surfsense_web/components/shared/llm-config-form.tsx @@ -551,7 +551,9 @@ export function LLMConfigForm({ render={({ field }) => (
- Enable Citations + + Enable Citations + Include [citation:id] references to source documents diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx index 81733487d..015d6ac07 100644 --- a/surfsense_web/components/ui/drawer.tsx +++ b/surfsense_web/components/ui/drawer.tsx @@ -9,12 +9,7 @@ function Drawer({ shouldScaleBackground = true, ...props }: React.ComponentProps) { - return ( - - ); + return ; } Drawer.displayName = "Drawer"; @@ -62,42 +57,20 @@ function DrawerContent({ } DrawerContent.displayName = "DrawerContent"; -function DrawerHeader({ - className, - ...props -}: React.HTMLAttributes) { - return ( -
- ); +function DrawerHeader({ className, ...props }: React.HTMLAttributes) { + return
; } DrawerHeader.displayName = "DrawerHeader"; -function DrawerFooter({ - className, - ...props -}: React.HTMLAttributes) { - return ( -
- ); +function DrawerFooter({ className, ...props }: React.HTMLAttributes) { + return
; } DrawerFooter.displayName = "DrawerFooter"; -function DrawerTitle({ - className, - ...props -}: React.ComponentProps) { +function DrawerTitle({ className, ...props }: React.ComponentProps) { return ( ); @@ -119,7 +92,10 @@ DrawerDescription.displayName = DrawerPrimitive.Description.displayName; function DrawerHandle({ className, ...props }: React.HTMLAttributes) { return ( -
+
); } DrawerHandle.displayName = "DrawerHandle"; @@ -137,4 +113,3 @@ export { DrawerDescription, DrawerHandle, }; - diff --git a/surfsense_web/components/ui/spinner.tsx b/surfsense_web/components/ui/spinner.tsx index eeed30a8a..22d190afa 100644 --- a/surfsense_web/components/ui/spinner.tsx +++ b/surfsense_web/components/ui/spinner.tsx @@ -1,34 +1,33 @@ import { cn } from "@/lib/utils"; interface SpinnerProps { - /** Size of the spinner */ - size?: "xs" | "sm" | "md" | "lg" | "xl"; - /** Whether to hide the track behind the spinner arc */ - hideTrack?: boolean; - /** Additional classes to apply */ - className?: string; + /** Size of the spinner */ + size?: "xs" | "sm" | "md" | "lg" | "xl"; + /** Whether to hide the track behind the spinner arc */ + hideTrack?: boolean; + /** Additional classes to apply */ + className?: string; } const sizeClasses = { - xs: "h-3 w-3 border-[1.5px]", - sm: "h-4 w-4 border-2", - md: "h-6 w-6 border-2", - lg: "h-8 w-8 border-[3px]", - xl: "h-10 w-10 border-4", + xs: "h-3 w-3 border-[1.5px]", + sm: "h-4 w-4 border-2", + md: "h-6 w-6 border-2", + lg: "h-8 w-8 border-[3px]", + xl: "h-10 w-10 border-4", }; export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) { - return ( - - ); + return ( + + ); } - diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 515ba5864..c1627ebee 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -146,4 +146,3 @@ export type InboxItem = z.infer; export type ConnectorIndexingInboxItem = z.infer; export type DocumentProcessingInboxItem = z.infer; export type NewMentionInboxItem = z.infer; - diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index bd4a6ee35..7ce33ac9a 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -190,9 +190,7 @@ export function useInbox( ORDER BY created_at DESC LIMIT ${PAGE_SIZE}`; - const params = typeFilter - ? [userId, searchSpaceId, typeFilter] - : [userId, searchSpaceId]; + const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; const db = client.db as any; @@ -310,10 +308,7 @@ export function useInbox( AND read = false AND created_at > '${cutoff}'`; - const result = await client.db.query<{ count: number }>(query, [ - userId, - searchSpaceId, - ]); + const result = await client.db.query<{ count: number }>(query, [userId, searchSpaceId]); if (mounted && result.rows?.[0]) { setTotalUnreadCount(Number(result.rows[0].count) || 0); } From c98cfac49fb4a66d02911e034f115bd92ec12b0a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:46:44 +0530 Subject: [PATCH 33/53] refactor: improve InboxSidebar and useInbox hook - Added LayoutGrid icon to the "All connectors" option in the InboxSidebar for better visual representation. - Simplified the return statement in getConnectorTypeDisplayName for improved readability. - Refactored filter and drawer components in InboxSidebar for cleaner code structure. - Removed unused totalUnreadCount state from useInbox hook and replaced it with a memoized calculation for better performance. - Implemented optimistic updates for marking inbox items as read, enhancing user experience with immediate feedback. --- .../layout/ui/sidebar/InboxSidebar.tsx | 85 ++++++++------ surfsense_web/hooks/use-inbox.ts | 105 ++++++------------ 2 files changed, 89 insertions(+), 101 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index a0a0d1f5e..48553cc85 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -9,6 +9,7 @@ import { CheckCircle2, History, Inbox, + LayoutGrid, ListFilter, Search, X, @@ -95,7 +96,13 @@ function getConnectorTypeDisplayName(connectorType: string): string { BAIDU_SEARCH_API: "Baidu", }; - return displayNames[connectorType] || connectorType.replace(/_/g, " ").replace(/CONNECTOR|API/gi, "").trim(); + return ( + displayNames[connectorType] || + connectorType + .replace(/_/g, " ") + .replace(/CONNECTOR|API/gi, "") + .trim() + ); } type InboxTab = "mentions" | "status"; @@ -142,7 +149,7 @@ export function InboxSidebar({ // Drawer state for filter menu (mobile only) const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); - + // Prefetch trigger ref - placed on item near the end const prefetchTriggerRef = useRef(null); @@ -239,8 +246,7 @@ export function InboxSidebar({ const query = searchQuery.toLowerCase(); items = items.filter( (item) => - item.title.toLowerCase().includes(query) || - item.message.toLowerCase().includes(query) + item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query) ); } @@ -453,15 +459,14 @@ export function InboxSidebar({ {t("filter") || "Filter"} - - {t("filter") || "Filter"} - + {t("filter") || "Filter"} - - + + @@ -484,7 +489,9 @@ export function InboxSidebar({ }} className={cn( "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", - activeFilter === "all" ? "bg-primary/10 text-primary" : "hover:bg-muted" + activeFilter === "all" + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > @@ -501,7 +508,9 @@ export function InboxSidebar({ }} className={cn( "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", - activeFilter === "unread" ? "bg-primary/10 text-primary" : "hover:bg-muted" + activeFilter === "unread" + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > @@ -527,10 +536,15 @@ export function InboxSidebar({ }} className={cn( "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", - selectedConnector === null ? "bg-primary/10 text-primary" : "hover:bg-muted" + selectedConnector === null + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > - {t("all_connectors") || "All connectors"} + + + {t("all_connectors") || "All connectors"} + {selectedConnector === null && } {uniqueConnectorTypes.map((connector) => ( @@ -543,14 +557,18 @@ export function InboxSidebar({ }} className={cn( "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", - selectedConnector === connector.type ? "bg-primary/10 text-primary" : "hover:bg-muted" + selectedConnector === connector.type + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > {getConnectorIcon(connector.type, "h-4 w-4")} {connector.displayName} - {selectedConnector === connector.type && } + {selectedConnector === connector.type && ( + + )} ))}
@@ -569,21 +587,18 @@ export function InboxSidebar({ - - - {t("filter") || "Filter"} - + {t("filter") || "Filter"} - + {t("filter") || "Filter"} @@ -616,7 +631,10 @@ export function InboxSidebar({ onClick={() => setSelectedConnector(null)} className="flex items-center justify-between" > - {t("all_connectors") || "All connectors"} + + + {t("all_connectors") || "All connectors"} + {selectedConnector === null && } {uniqueConnectorTypes.map((connector) => ( @@ -629,7 +647,9 @@ export function InboxSidebar({ {getConnectorIcon(connector.type, "h-4 w-4")} {connector.displayName} - {selectedConnector === connector.type && } + {selectedConnector === connector.type && ( + + )} ))} @@ -723,7 +743,8 @@ export function InboxSidebar({ {filteredItems.map((item, index) => { const isMarkingAsRead = markingAsReadId === item.id; // Place prefetch trigger on 5th item from end (only if not searching) - const isPrefetchTrigger = !searchQuery && hasMore && index === filteredItems.length - 5; + const isPrefetchTrigger = + !searchQuery && hasMore && index === filteredItems.length - 5; return (
)} -

- {getEmptyStateMessage().title} -

+

{getEmptyStateMessage().title}

{getEmptyStateMessage().hint}

diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 7ce33ac9a..7c421c341 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; import { authenticatedFetch } from "@/lib/auth-utils"; import type { SyncHandle } from "@/lib/electric/client"; @@ -79,7 +79,6 @@ export function useInbox( const electricClient = useElectricClient(); const [inboxItems, setInboxItems] = useState([]); - const [totalUnreadCount, setTotalUnreadCount] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); @@ -87,9 +86,14 @@ export function useInbox( const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const userSyncKeyRef = useRef(null); + // Calculate unread count from inboxItems (includes both recent and older when loaded) + // This ensures the count is always in sync with what's displayed + const totalUnreadCount = useMemo(() => { + return inboxItems.filter((item) => !item.read).length; + }, [inboxItems]); + // EFFECT 1: Electric SQL sync for real-time updates useEffect(() => { if (!userId || !electricClient) { @@ -287,69 +291,6 @@ export function useInbox( }; }, [userId, searchSpaceId, typeFilter, electricClient]); - // EFFECT 3: Unread count with live updates - useEffect(() => { - if (!userId || !electricClient) return; - - const client = electricClient; - let mounted = true; - - async function updateUnreadCount() { - if (unreadCountLiveQueryRef.current) { - unreadCountLiveQueryRef.current.unsubscribe(); - unreadCountLiveQueryRef.current = null; - } - - try { - const cutoff = getSyncCutoffDate(); - const query = `SELECT COUNT(*) as count FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL) - AND read = false - AND created_at > '${cutoff}'`; - - const result = await client.db.query<{ count: number }>(query, [userId, searchSpaceId]); - if (mounted && result.rows?.[0]) { - setTotalUnreadCount(Number(result.rows[0].count) || 0); - } - - const db = client.db as any; - if (db.live?.query) { - const liveQuery = await db.live.query(query, [userId, searchSpaceId]); - - if (!mounted) { - liveQuery.unsubscribe?.(); - return; - } - - if (liveQuery.subscribe) { - liveQuery.subscribe((result: { rows: { count: number }[] }) => { - if (mounted && result.rows?.[0]) { - setTotalUnreadCount(Number(result.rows[0].count) || 0); - } - }); - } - - if (liveQuery.unsubscribe) { - unreadCountLiveQueryRef.current = liveQuery; - } - } - } catch (err) { - console.error("[useInbox] Unread count error:", err); - } - } - - updateUnreadCount(); - - return () => { - mounted = false; - if (unreadCountLiveQueryRef.current) { - unreadCountLiveQueryRef.current.unsubscribe(); - unreadCountLiveQueryRef.current = null; - } - }; - }, [userId, searchSpaceId, electricClient]); - // loadMore - Pure cursor-based pagination, no race conditions // Cursor is computed from current state, not stored in refs const loadMore = useCallback(async () => { @@ -408,30 +349,58 @@ export function useInbox( } }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); - // Mark inbox item as read + // Mark inbox item as read with optimistic update const markAsRead = useCallback(async (itemId: number) => { + // Optimistic update: mark as read immediately for instant UI feedback + setInboxItems((prev) => + prev.map((item) => (item.id === itemId ? { ...item, read: true } : item)) + ); + try { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`, { method: "PATCH" } ); + + if (!response.ok) { + // Rollback on error + setInboxItems((prev) => + prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) + ); + } + // If successful, Electric SQL will sync the change and live query will update + // This ensures eventual consistency even if optimistic update was wrong return response.ok; } catch (err) { console.error("Failed to mark as read:", err); + // Rollback on error + setInboxItems((prev) => + prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) + ); return false; } }, []); - // Mark all inbox items as read + // Mark all inbox items as read with optimistic update const markAllAsRead = useCallback(async () => { + // Optimistic update: mark all as read immediately for instant UI feedback + setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); + try { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, { method: "PATCH" } ); + + if (!response.ok) { + console.error("Failed to mark all as read"); + // On error, let Electric SQL sync correct the state + } + // Electric SQL will sync and live query will ensure consistency return response.ok; } catch (err) { console.error("Failed to mark all as read:", err); + // On error, let Electric SQL sync correct the state return false; } }, []); From 00596f991d5b6fc5afea5028089fbdc3e1b052c0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:32:25 +0530 Subject: [PATCH 34/53] refactor: enhance inbox functionality with type guards and API service integration - Introduced type guards for metadata in InboxSidebar to ensure safe access and improve type safety. - Refactored the useInbox hook to utilize the new notifications API service for fetching notifications, enhancing validation and error handling. - Added new API request/response schemas for notifications, improving structure and clarity. - Updated logic for loading and marking notifications as read, ensuring consistent state management and user experience. --- .../layout/ui/sidebar/InboxSidebar.tsx | 77 +++++++----- surfsense_web/contracts/types/inbox.types.ts | 115 ++++++++++++++++++ surfsense_web/hooks/use-inbox.ts | 107 ++++++---------- .../lib/apis/notifications-api.service.ts | 94 ++++++++++++++ 4 files changed, 294 insertions(+), 99 deletions(-) create mode 100644 surfsense_web/lib/apis/notifications-api.service.ts diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 48553cc85..166d77eca 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -43,7 +43,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { InboxItem } from "@/hooks/use-inbox"; import { useMediaQuery } from "@/hooks/use-media-query"; -import type { ConnectorIndexingMetadata } from "@/contracts/types/inbox.types"; +import { + type ConnectorIndexingMetadata, + type NewMentionMetadata, + isConnectorIndexingMetadata, + isNewMentionMetadata, +} from "@/contracts/types/inbox.types"; import { cn } from "@/lib/utils"; /** @@ -206,9 +211,9 @@ export function InboxSidebar({ statusItems .filter((item) => item.type === "connector_indexing") .forEach((item) => { - const metadata = item.metadata as ConnectorIndexingMetadata; - if (metadata?.connector_type) { - connectorTypes.add(metadata.connector_type); + // Use type guard for safe metadata access + if (isConnectorIndexingMetadata(item.metadata)) { + connectorTypes.add(item.metadata.connector_type); } }); @@ -234,8 +239,11 @@ export function InboxSidebar({ if (activeTab === "status" && selectedConnector) { items = items.filter((item) => { if (item.type === "connector_indexing") { - const metadata = item.metadata as ConnectorIndexingMetadata; - return metadata?.connector_type === selectedConnector; + // Use type guard for safe metadata access + if (isConnectorIndexingMetadata(item.metadata)) { + return item.metadata.connector_type === selectedConnector; + } + return false; } return false; // Hide document_processing when a specific connector is selected }); @@ -297,21 +305,20 @@ export function InboxSidebar({ } if (item.type === "new_mention") { - const metadata = item.metadata as { - thread_id?: number; - comment_id?: number; - }; - const searchSpaceId = item.search_space_id; - const threadId = metadata?.thread_id; - const commentId = metadata?.comment_id; + // Use type guard for safe metadata access + if (isNewMentionMetadata(item.metadata)) { + const searchSpaceId = item.search_space_id; + const threadId = item.metadata.thread_id; + const commentId = item.metadata.comment_id; - if (searchSpaceId && threadId) { - const url = commentId - ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` - : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(url); + if (searchSpaceId && threadId) { + const url = commentId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` + : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } } } }, @@ -348,27 +355,35 @@ export function InboxSidebar({ const getStatusIcon = (item: InboxItem) => { // For mentions, show the author's avatar with initials fallback if (item.type === "new_mention") { - const metadata = item.metadata as { - author_name?: string; - author_avatar_url?: string | null; - author_email?: string; - }; - const authorName = metadata?.author_name; - const avatarUrl = metadata?.author_avatar_url; - const authorEmail = metadata?.author_email; + // Use type guard for safe metadata access + if (isNewMentionMetadata(item.metadata)) { + const authorName = item.metadata.author_name; + const avatarUrl = item.metadata.author_avatar_url; + const authorEmail = item.metadata.author_email; + return ( + + {avatarUrl && } + + {getInitials(authorName, authorEmail)} + + + ); + } + // Fallback for invalid metadata return ( - {avatarUrl && } - {getInitials(authorName, authorEmail)} + {getInitials(null, null)} ); } // For status items (connector/document), show status icons - const status = item.metadata?.status as string | undefined; + // Safely access status from metadata + const metadata = item.metadata as Record; + const status = typeof metadata?.status === "string" ? metadata.status : undefined; switch (status) { case "in_progress": diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index c1627ebee..12ebfe1e9 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -133,7 +133,115 @@ export const newMentionInboxItem = inboxItem.extend({ metadata: newMentionMetadata, }); +// ============================================================================= +// API Request/Response Schemas +// ============================================================================= + +/** + * Request schema for getting notifications + */ +export const getNotificationsRequest = z.object({ + queryParams: z.object({ + search_space_id: z.number().optional(), + type: inboxItemTypeEnum.optional(), + before_date: z.string().optional(), + limit: z.number().min(1).max(100).optional(), + offset: z.number().min(0).optional(), + }), +}); + +/** + * Response schema for listing notifications + */ +export const getNotificationsResponse = z.object({ + items: z.array(inboxItem), + total: z.number(), + has_more: z.boolean(), + next_offset: z.number().nullable(), +}); + +/** + * Request schema for marking a single notification as read + */ +export const markNotificationReadRequest = z.object({ + notificationId: z.number(), +}); + +/** + * Response schema for mark as read operations + */ +export const markNotificationReadResponse = z.object({ + success: z.boolean(), + message: z.string(), +}); + +/** + * Response schema for mark all as read operation + */ +export const markAllNotificationsReadResponse = z.object({ + success: z.boolean(), + message: z.string(), + updated_count: z.number(), +}); + +// ============================================================================= +// Type Guards for Metadata +// ============================================================================= + +/** + * Type guard for ConnectorIndexingMetadata + */ +export function isConnectorIndexingMetadata( + metadata: unknown +): metadata is ConnectorIndexingMetadata { + return connectorIndexingMetadata.safeParse(metadata).success; +} + +/** + * Type guard for DocumentProcessingMetadata + */ +export function isDocumentProcessingMetadata( + metadata: unknown +): metadata is DocumentProcessingMetadata { + return documentProcessingMetadata.safeParse(metadata).success; +} + +/** + * Type guard for NewMentionMetadata + */ +export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata { + return newMentionMetadata.safeParse(metadata).success; +} + +/** + * Safe metadata parser - returns typed metadata or null + */ +export function parseInboxItemMetadata( + type: InboxItemTypeEnum, + metadata: unknown +): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null { + switch (type) { + case "connector_indexing": { + const result = connectorIndexingMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + case "document_processing": { + const result = documentProcessingMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + case "new_mention": { + const result = newMentionMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + default: + return null; + } +} + +// ============================================================================= // Inferred types +// ============================================================================= + export type InboxItemTypeEnum = z.infer; export type InboxItemStatusEnum = z.infer; export type DocumentProcessingStageEnum = z.infer; @@ -146,3 +254,10 @@ export type InboxItem = z.infer; export type ConnectorIndexingInboxItem = z.infer; export type DocumentProcessingInboxItem = z.infer; export type NewMentionInboxItem = z.infer; + +// API Request/Response types +export type GetNotificationsRequest = z.infer; +export type GetNotificationsResponse = z.infer; +export type MarkNotificationReadRequest = z.infer; +export type MarkNotificationReadResponse = z.infer; +export type MarkAllNotificationsReadResponse = z.infer; diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 7c421c341..7f0bd59ef 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import type { SyncHandle } from "@/lib/electric/client"; import { useElectricClient } from "@/lib/electric/context"; @@ -198,7 +198,7 @@ export function useInbox( const db = client.db as any; - // Initial fetch from PGLite + // Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync const result = await client.db.query(query, params); if (mounted && result.rows) { @@ -213,30 +213,20 @@ export function useInbox( "[useInbox] Electric returned 0 items, checking API for older notifications" ); try { - const apiParams = new URLSearchParams(); - if (searchSpaceId !== null) { - apiParams.append("search_space_id", String(searchSpaceId)); - } - if (typeFilter) { - apiParams.append("type", typeFilter); - } - apiParams.append("limit", String(PAGE_SIZE)); + // Use the API service with proper Zod validation for API responses + const data = await notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + type: typeFilter ?? undefined, + limit: PAGE_SIZE, + }, + }); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${apiParams.toString()}` - ); - - if (response.ok && mounted) { - const data = await response.json(); - const apiItems: InboxItem[] = data.items.map((item: any) => ({ - ...item, - metadata: item.metadata || {}, - })); - - if (apiItems.length > 0) { - setInboxItems(apiItems); + if (mounted) { + if (data.items.length > 0) { + setInboxItems(data.items); } - setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE); + setHasMore(data.has_more); } } catch (err) { console.error("[useInbox] API fallback failed:", err); @@ -254,10 +244,12 @@ export function useInbox( } if (liveQuery.subscribe) { + // Live query data comes from PGlite - no validation needed liveQuery.subscribe((result: { rows: InboxItem[] }) => { if (mounted && result.rows) { + const liveItems = result.rows; + setInboxItems((prev) => { - const liveItems = result.rows; const liveItemIds = new Set(liveItems.map((item) => item.id)); // FIXED: Keep ALL items not in live result (not just slice) @@ -305,43 +297,26 @@ export function useInbox( const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null; const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null; - const params = new URLSearchParams(); - if (searchSpaceId !== null) { - params.append("search_space_id", String(searchSpaceId)); - } - if (typeFilter) { - params.append("type", typeFilter); - } - // Only add before_date if we have a cursor - // Without before_date, API returns newest items first - if (beforeDate) { - params.append("before_date", beforeDate); - } - params.append("limit", String(PAGE_SIZE)); - console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)"); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${params.toString()}` - ); + // Use the API service with proper Zod validation + const data = await notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + type: typeFilter ?? undefined, + before_date: beforeDate ?? undefined, + limit: PAGE_SIZE, + }, + }); - if (!response.ok) { - throw new Error("Failed to fetch notifications"); - } - - const data = await response.json(); - const apiItems: InboxItem[] = data.items.map((item: any) => ({ - ...item, - metadata: item.metadata || {}, - })); - - if (apiItems.length > 0) { + if (data.items.length > 0) { // Functional update ensures we always merge with latest state - setInboxItems((prev) => deduplicateAndSort([...prev, ...apiItems])); + // Items are already validated by the API service + setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items])); } - // Use API's has_more flag if available, otherwise check count - setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE); + // Use API's has_more flag + setHasMore(data.has_more); } catch (err) { console.error("[useInbox] Load more failed:", err); } finally { @@ -357,12 +332,10 @@ export function useInbox( ); try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`, - { method: "PATCH" } - ); + // Use the API service with proper Zod validation + const result = await notificationsApiService.markAsRead({ notificationId: itemId }); - if (!response.ok) { + if (!result.success) { // Rollback on error setInboxItems((prev) => prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) @@ -370,7 +343,7 @@ export function useInbox( } // If successful, Electric SQL will sync the change and live query will update // This ensures eventual consistency even if optimistic update was wrong - return response.ok; + return result.success; } catch (err) { console.error("Failed to mark as read:", err); // Rollback on error @@ -387,17 +360,15 @@ export function useInbox( setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, - { method: "PATCH" } - ); + // Use the API service with proper Zod validation + const result = await notificationsApiService.markAllAsRead(); - if (!response.ok) { + if (!result.success) { console.error("Failed to mark all as read"); // On error, let Electric SQL sync correct the state } // Electric SQL will sync and live query will ensure consistency - return response.ok; + return result.success; } catch (err) { console.error("Failed to mark all as read:", err); // On error, let Electric SQL sync correct the state diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts new file mode 100644 index 000000000..a2489cdee --- /dev/null +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -0,0 +1,94 @@ +import { + type GetNotificationsRequest, + type GetNotificationsResponse, + type MarkAllNotificationsReadResponse, + type MarkNotificationReadRequest, + type MarkNotificationReadResponse, + getNotificationsRequest, + getNotificationsResponse, + markAllNotificationsReadResponse, + markNotificationReadRequest, + markNotificationReadResponse, +} from "@/contracts/types/inbox.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class NotificationsApiService { + /** + * Get notifications with pagination + */ + getNotifications = async ( + request: GetNotificationsRequest + ): Promise => { + const parsedRequest = getNotificationsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { queryParams } = parsedRequest.data; + + // Build query string from params + const params = new URLSearchParams(); + + if (queryParams.search_space_id !== undefined) { + params.append("search_space_id", String(queryParams.search_space_id)); + } + if (queryParams.type) { + params.append("type", queryParams.type); + } + if (queryParams.before_date) { + params.append("before_date", queryParams.before_date); + } + if (queryParams.limit !== undefined) { + params.append("limit", String(queryParams.limit)); + } + if (queryParams.offset !== undefined) { + params.append("offset", String(queryParams.offset)); + } + + const queryString = params.toString(); + + return baseApiService.get( + `/api/v1/notifications${queryString ? `?${queryString}` : ""}`, + getNotificationsResponse + ); + }; + + /** + * Mark a single notification as read + */ + markAsRead = async ( + request: MarkNotificationReadRequest + ): Promise => { + const parsedRequest = markNotificationReadRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { notificationId } = parsedRequest.data; + + return baseApiService.patch( + `/api/v1/notifications/${notificationId}/read`, + markNotificationReadResponse + ); + }; + + /** + * Mark all notifications as read + */ + markAllAsRead = async (): Promise => { + return baseApiService.patch( + "/api/v1/notifications/read-all", + markAllNotificationsReadResponse + ); + }; +} + +export const notificationsApiService = new NotificationsApiService(); + From 3ab9cc84854c8e050ad76b7c45228154777b6a74 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 15:05:00 +0200 Subject: [PATCH 35/53] Restore API-based comments and remove unstable live sync integration --- .../new-chat/[[...chat_id]]/page.tsx | 43 ------------------- .../assistant-ui/assistant-message.tsx | 10 +++-- .../comment-panel-container.tsx | 15 ++++--- .../contracts/types/chat-messages.types.ts | 2 +- surfsense_web/hooks/use-chat-messages-live.ts | 7 ++- 5 files changed, 22 insertions(+), 55 deletions(-) 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 865dd6e68..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 @@ -33,7 +33,6 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; -import { useChatMessagesLive } from "@/hooks/use-chat-messages-live"; import { getBearerToken } from "@/lib/auth-utils"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { @@ -258,9 +257,6 @@ export default function NewChatPage() { // Get current user for author info in shared chats const { data: currentUser } = useAtomValue(currentUserAtom); - // Live sync for other users' messages (shared chats) - const { messages: liveMessages } = useChatMessagesLive(threadId); - // Create the attachment adapter for file processing const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); @@ -413,45 +409,6 @@ export default function NewChatPage() { }); }, [currentThread, setCurrentThreadState]); - // Live sync: Merge messages from other users (shared chats) - useEffect(() => { - if (!liveMessages.length || !currentUser?.id || isRunning) return; - - // Get IDs of messages we already have locally - const localMessageIds = new Set( - messages - .map((m) => { - // Extract numeric ID from "msg-{id}" format - const match = m.id?.match(/^msg-(\d+)$/); - return match ? Number.parseInt(match[1], 10) : null; - }) - .filter((id): id is number => id !== null) - ); - - // Find live messages from OTHER users that we don't have locally - const newOtherUserMessages = liveMessages.filter((liveMsg) => { - // Skip if we already have this message - if (localMessageIds.has(liveMsg.id)) return false; - // Skip if this is our own message (we added it optimistically) - if (liveMsg.author_id === currentUser.id) return false; - return true; - }); - - if (newOtherUserMessages.length > 0) { - // Convert and add new messages from other users - const converted = newOtherUserMessages.map(convertToThreadMessage); - setMessages((prev) => { - // Merge and sort by ID to maintain order - const merged = [...prev, ...converted]; - return merged.sort((a, b) => { - const aId = Number.parseInt((a.id ?? "").replace(/^msg-/, ""), 10) || 0; - const bId = Number.parseInt((b.id ?? "").replace(/^msg-/, ""), 10) || 0; - return aId - bId; - }); - }); - } - }, [liveMessages, currentUser?.id, messages, isRunning]); - // Cancel ongoing request const cancelRun = useCallback(async () => { if (abortControllerRef.current) { diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 513242d1b..681dc315a 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -25,7 +25,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger"; -import { useCommentsLive } from "@/hooks/use-comments-live"; +import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; @@ -115,8 +115,12 @@ export const AssistantMessage: FC = () => { const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; - // Live sync for real-time comment count - const { commentCount } = useCommentsLive(dbMessageId); + const { data: commentsData } = useComments({ + messageId: dbMessageId ?? 0, + enabled: !!dbMessageId, + }); + + const commentCount = commentsData?.total_count ?? 0; const hasComments = commentCount > 0; const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId; const showCommentPanel = hasComments || isAddingComment; diff --git a/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx b/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx index 8281ca8fd..197ac0798 100644 --- a/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx +++ b/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx @@ -10,7 +10,7 @@ import { } from "@/atoms/chat-comments/comments-mutation.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { useCommentsLive } from "@/hooks/use-comments-live"; +import { useComments } from "@/hooks/use-comments"; import { CommentPanel } from "../comment-panel/comment-panel"; import type { CommentPanelContainerProps } from "./types"; import { transformComment, transformMember } from "./utils"; @@ -21,10 +21,10 @@ export function CommentPanelContainer({ maxHeight, variant = "desktop", }: CommentPanelContainerProps) { - // Live sync for real-time comment updates - const { comments: liveComments, isLoading: isCommentsLoading } = useCommentsLive( - isOpen ? messageId : null - ); + const { data: commentsData, isLoading: isCommentsLoading } = useComments({ + messageId, + enabled: isOpen, + }); const [{ data: membersData, isLoading: isMembersLoading }] = useAtom(membersAtom); const [{ data: currentUser }] = useAtom(currentUserAtom); @@ -35,8 +35,9 @@ export function CommentPanelContainer({ const [{ mutate: deleteComment, isPending: isDeleting }] = useAtom(deleteCommentMutationAtom); const commentThreads = useMemo(() => { - return liveComments.map(transformComment); - }, [liveComments]); + if (!commentsData?.comments) return []; + return commentsData.comments.map(transformComment); + }, [commentsData]); const members = useMemo(() => { if (!membersData) return []; diff --git a/surfsense_web/contracts/types/chat-messages.types.ts b/surfsense_web/contracts/types/chat-messages.types.ts index 01edef3f2..faba71bff 100644 --- a/surfsense_web/contracts/types/chat-messages.types.ts +++ b/surfsense_web/contracts/types/chat-messages.types.ts @@ -6,7 +6,7 @@ import { z } from "zod"; export const rawMessage = z.object({ id: z.number(), thread_id: z.number(), - role: z.enum(["user", "assistant", "system"]), + role: z.string(), content: z.unknown(), author_id: z.string().nullable(), created_at: z.string(), diff --git a/surfsense_web/hooks/use-chat-messages-live.ts b/surfsense_web/hooks/use-chat-messages-live.ts index 39341d479..4a8ae97e6 100644 --- a/surfsense_web/hooks/use-chat-messages-live.ts +++ b/surfsense_web/hooks/use-chat-messages-live.ts @@ -55,10 +55,15 @@ export function useChatMessagesLive(threadId: number | null) { // Transform raw messages to MessageRecord with author info return [...messagesData].map((msg): MessageRecord => { const author = msg.author_id ? memberMap.get(msg.author_id) : null; + + const role = (typeof msg.role === "string" ? msg.role.toLowerCase() : msg.role) as + | "user" + | "assistant" + | "system"; return { id: msg.id, thread_id: msg.thread_id, - role: msg.role, + role, content: msg.content, created_at: msg.created_at, author_id: msg.author_id, From 3a1fa25a6fe1765641703baebd2a423f30975f20 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:08:13 +0530 Subject: [PATCH 36/53] refactor: streamline markAllAsRead method in notifications API service - Simplified the return statement in the markAllAsRead method for improved readability and consistency. --- surfsense_web/lib/apis/notifications-api.service.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index a2489cdee..aff220f17 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -83,12 +83,8 @@ class NotificationsApiService { * Mark all notifications as read */ markAllAsRead = async (): Promise => { - return baseApiService.patch( - "/api/v1/notifications/read-all", - markAllNotificationsReadResponse - ); + return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse); }; } export const notificationsApiService = new NotificationsApiService(); - From ac7d84571dbbe834d01ecf653d1c95df288946a5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 17:27:42 +0200 Subject: [PATCH 37/53] Add thread_id to chat_comments for Electric sync --- .../75_add_thread_id_to_chat_comments.py | 68 +++++++++++++++++++ surfsense_backend/app/db.py | 8 +++ .../app/services/chat_comments_service.py | 6 +- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 surfsense_backend/alembic/versions/75_add_thread_id_to_chat_comments.py diff --git a/surfsense_backend/alembic/versions/75_add_thread_id_to_chat_comments.py b/surfsense_backend/alembic/versions/75_add_thread_id_to_chat_comments.py new file mode 100644 index 000000000..ceeb589f4 --- /dev/null +++ b/surfsense_backend/alembic/versions/75_add_thread_id_to_chat_comments.py @@ -0,0 +1,68 @@ +"""Add thread_id to chat_comments for denormalized Electric subscriptions + +This denormalization allows a single Electric SQL subscription per thread +instead of one per message, significantly reducing connection overhead. + +Revision ID: 75 +Revises: 74 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "75" +down_revision: str | None = "74" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add thread_id column to chat_comments and backfill from messages.""" + # Add the column (nullable initially for backfill) + op.execute( + """ + ALTER TABLE chat_comments + ADD COLUMN IF NOT EXISTS thread_id INTEGER; + """ + ) + + # Backfill thread_id from the related message + op.execute( + """ + UPDATE chat_comments c + SET thread_id = m.thread_id + FROM new_chat_messages m + WHERE c.message_id = m.id + AND c.thread_id IS NULL; + """ + ) + + # Make it NOT NULL after backfill + op.execute( + """ + ALTER TABLE chat_comments + ALTER COLUMN thread_id SET NOT NULL; + """ + ) + + # Add FK constraint + op.execute( + """ + ALTER TABLE chat_comments + ADD CONSTRAINT fk_chat_comments_thread_id + FOREIGN KEY (thread_id) REFERENCES new_chat_threads(id) ON DELETE CASCADE; + """ + ) + + # Add index for efficient Electric subscriptions by thread + op.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id)" + ) + + +def downgrade() -> None: + """Remove thread_id column from chat_comments.""" + op.execute("DROP INDEX IF EXISTS idx_chat_comments_thread_id") + op.execute("ALTER TABLE chat_comments DROP CONSTRAINT IF EXISTS fk_chat_comments_thread_id") + op.execute("ALTER TABLE chat_comments DROP COLUMN IF EXISTS thread_id") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 35b512c5e..181f78101 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -413,6 +413,13 @@ class ChatComment(BaseModel, TimestampMixin): nullable=False, index=True, ) + # Denormalized thread_id for efficient Electric SQL subscriptions (one per thread) + thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) parent_id = Column( Integer, ForeignKey("chat_comments.id", ondelete="CASCADE"), @@ -436,6 +443,7 @@ class ChatComment(BaseModel, TimestampMixin): # Relationships message = relationship("NewChatMessage", back_populates="comments") + thread = relationship("NewChatThread") author = relationship("User") parent = relationship( "ChatComment", remote_side="ChatComment.id", backref="replies" diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index fa26bf6d5..7e07a56e6 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -281,8 +281,10 @@ async def create_comment( detail="You don't have permission to create comments in this search space", ) + thread = message.thread comment = ChatComment( message_id=message_id, + thread_id=thread.id, # Denormalized for efficient Electric subscriptions author_id=user.id, content=content, ) @@ -299,7 +301,6 @@ async def create_comment( user_names = await get_user_names_for_mentions(session, set(mentions_map.keys())) # 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(): @@ -391,8 +392,10 @@ async def create_reply( detail="You don't have permission to create comments in this search space", ) + thread = parent_comment.message.thread reply = ChatComment( message_id=parent_comment.message_id, + thread_id=thread.id, # Denormalized for efficient Electric subscriptions parent_id=comment_id, author_id=user.id, content=content, @@ -410,7 +413,6 @@ async def create_reply( user_names = await get_user_names_for_mentions(session, set(mentions_map.keys())) # 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(): From 4b57ba9d2bac1883d409bc337ea7a77f94f4f803 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 17:56:45 +0200 Subject: [PATCH 38/53] Add chat_comments table to PGlite schema and thread_id to types --- .../contracts/types/chat-comments.types.ts | 1 + surfsense_web/lib/electric/client.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/surfsense_web/contracts/types/chat-comments.types.ts b/surfsense_web/contracts/types/chat-comments.types.ts index c3b32be9d..46e064a4e 100644 --- a/surfsense_web/contracts/types/chat-comments.types.ts +++ b/surfsense_web/contracts/types/chat-comments.types.ts @@ -6,6 +6,7 @@ import { z } from "zod"; export const rawComment = z.object({ id: z.number(), message_id: z.number(), + thread_id: z.number(), // Denormalized for efficient Electric subscriptions parent_id: z.number().nullable(), author_id: z.string().nullable(), content: z.string(), diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index d900ddb0a..84df6c905 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -240,6 +240,24 @@ export async function initElectric(userId: string): Promise { CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id); `); + // Create chat_comments table for live comment sync + await db.exec(` + CREATE TABLE IF NOT EXISTS chat_comments ( + id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL, + thread_id INTEGER NOT NULL, + parent_id INTEGER, + author_id TEXT, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id); + CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id); + CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id); + `); + const electricUrl = getElectricUrl(); // STEP 4: Create the client wrapper From 0b8fed7304aee0275f8e249bcd81e80cbeca6087 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 17:57:20 +0200 Subject: [PATCH 39/53] Add thread-level Electric sync for comments, remove per-message hooks --- .../components/assistant-ui/thread.tsx | 4 + surfsense_web/hooks/use-comments-electric.ts | 361 ++++++++++++++++++ surfsense_web/hooks/use-comments-live.ts | 154 -------- 3 files changed, 365 insertions(+), 154 deletions(-) create mode 100644 surfsense_web/hooks/use-comments-electric.ts delete mode 100644 surfsense_web/hooks/use-comments-live.ts diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d65372c24..e1169867c 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -62,6 +62,7 @@ import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import type { Document } from "@/contracts/types/document.types"; import { useChatSessionState } from "@/hooks/use-chat-session-state"; +import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { cn } from "@/lib/utils"; interface ThreadProps { @@ -238,6 +239,9 @@ const Composer: FC = () => { const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; + // Sync comments for the entire thread via Electric SQL (one subscription per thread) + useCommentsElectric(threadId); + // Auto-focus editor on new chat page after mount useEffect(() => { if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) { diff --git a/surfsense_web/hooks/use-comments-electric.ts b/surfsense_web/hooks/use-comments-electric.ts new file mode 100644 index 000000000..83a019ef3 --- /dev/null +++ b/surfsense_web/hooks/use-comments-electric.ts @@ -0,0 +1,361 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import type { + Comment, + CommentReply, + Author, +} from "@/contracts/types/chat-comments.types"; +import type { Membership } from "@/contracts/types/members.types"; +import type { SyncHandle } from "@/lib/electric/client"; +import { useElectricClient } from "@/lib/electric/context"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +// Raw comment from PGlite local database +interface RawCommentRow { + id: number; + message_id: number; + thread_id: number; + parent_id: number | null; + author_id: string | null; + content: string; + created_at: string; + updated_at: string; +} + +// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN) +const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g; + +type MemberInfo = Pick; + +/** + * Render mentions in content by replacing @[uuid] with @{DisplayName} + */ +function renderMentions(content: string, memberMap: Map): string { + return content.replace(MENTION_PATTERN, (match, uuid) => { + const member = memberMap.get(uuid); + if (member?.user_display_name) { + return `@{${member.user_display_name}}`; + } + return match; + }); +} + +/** + * Build member lookup map from membersData + */ +function buildMemberMap(membersData: Membership[] | undefined): Map { + const map = new Map(); + if (membersData) { + for (const m of membersData) { + map.set(m.user_id, { + user_display_name: m.user_display_name, + user_avatar_url: m.user_avatar_url, + user_email: m.user_email, + }); + } + } + return map; +} + +/** + * Build author object from member data + */ +function buildAuthor(authorId: string | null, memberMap: Map): Author | null { + if (!authorId) return null; + const m = memberMap.get(authorId); + if (!m) return null; + return { + id: authorId, + display_name: m.user_display_name ?? null, + avatar_url: m.user_avatar_url ?? null, + email: m.user_email ?? "", + }; +} + +/** + * Check if a comment has been edited by comparing timestamps. + * Uses a small threshold to handle precision differences. + */ +function isEdited(createdAt: string, updatedAt: string): boolean { + const created = new Date(createdAt).getTime(); + const updated = new Date(updatedAt).getTime(); + // Consider edited if updated_at is more than 1 second after created_at + return updated - created > 1000; +} + +/** + * Transform raw comment to CommentReply + */ +function transformReply( + raw: RawCommentRow, + memberMap: Map, + currentUserId: string | undefined, + isOwner: boolean +): CommentReply { + return { + id: raw.id, + content: raw.content, + content_rendered: renderMentions(raw.content, memberMap), + author: buildAuthor(raw.author_id, memberMap), + created_at: raw.created_at, + updated_at: raw.updated_at, + is_edited: isEdited(raw.created_at, raw.updated_at), + can_edit: currentUserId === raw.author_id, + can_delete: currentUserId === raw.author_id || isOwner, + }; +} + +/** + * Transform raw comments to Comment with replies + */ +function transformComments( + rawComments: RawCommentRow[], + memberMap: Map, + currentUserId: string | undefined, + isOwner: boolean +): Map { + // Group comments by message_id + const byMessage = new Map }>(); + + for (const raw of rawComments) { + if (!byMessage.has(raw.message_id)) { + byMessage.set(raw.message_id, { topLevel: [], replies: new Map() }); + } + const group = byMessage.get(raw.message_id)!; + + if (raw.parent_id === null) { + group.topLevel.push(raw); + } else { + if (!group.replies.has(raw.parent_id)) { + group.replies.set(raw.parent_id, []); + } + group.replies.get(raw.parent_id)!.push(raw); + } + } + + // Transform to Comment objects grouped by message_id + const result = new Map(); + + for (const [messageId, group] of byMessage) { + const comments: Comment[] = group.topLevel.map((raw) => { + const replies = (group.replies.get(raw.id) || []) + .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + .map((r) => transformReply(r, memberMap, currentUserId, isOwner)); + + return { + id: raw.id, + message_id: raw.message_id, + content: raw.content, + content_rendered: renderMentions(raw.content, memberMap), + author: buildAuthor(raw.author_id, memberMap), + created_at: raw.created_at, + updated_at: raw.updated_at, + is_edited: isEdited(raw.created_at, raw.updated_at), + can_edit: currentUserId === raw.author_id, + can_delete: currentUserId === raw.author_id || isOwner, + reply_count: replies.length, + replies, + }; + }); + + // Sort by created_at + comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + result.set(messageId, comments); + } + + return result; +} + +/** + * Hook for syncing comments with Electric SQL real-time sync. + * + * Syncs ALL comments for a thread in ONE subscription, then updates + * React Query cache for each message. This avoids N subscriptions for N messages. + * + * @param threadId - The thread ID to sync comments for + */ +export function useCommentsElectric(threadId: number | null) { + const electricClient = useElectricClient(); + const queryClient = useQueryClient(); + + const { data: membersData } = useAtomValue(membersAtom); + const { data: currentUser } = useAtomValue(currentUserAtom); + const { data: myAccess } = useAtomValue(myAccessAtom); + + const memberMap = useMemo(() => buildMemberMap(membersData), [membersData]); + const currentUserId = currentUser?.id; + const isOwner = myAccess?.is_owner ?? false; + + // Use refs for values needed in live query callback to avoid stale closures + const memberMapRef = useRef(memberMap); + const currentUserIdRef = useRef(currentUserId); + const isOwnerRef = useRef(isOwner); + const queryClientRef = useRef(queryClient); + + // Keep refs updated + useEffect(() => { + memberMapRef.current = memberMap; + currentUserIdRef.current = currentUserId; + isOwnerRef.current = isOwner; + queryClientRef.current = queryClient; + }, [memberMap, currentUserId, isOwner, queryClient]); + + const syncHandleRef = useRef(null); + const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); + const syncKeyRef = useRef(null); + + // Stable callback that uses refs for fresh values + const updateReactQueryCache = useCallback((rows: RawCommentRow[]) => { + const commentsByMessage = transformComments( + rows, + memberMapRef.current, + currentUserIdRef.current, + isOwnerRef.current + ); + + for (const [messageId, comments] of commentsByMessage) { + const cacheKey = cacheKeys.comments.byMessage(messageId); + queryClientRef.current.setQueryData(cacheKey, { + comments, + total_count: comments.length, + }); + } + }, []); + + useEffect(() => { + if (!threadId || !electricClient) { + return; + } + + const syncKey = `comments_${threadId}`; + if (syncKeyRef.current === syncKey) { + return; + } + + // Capture in local variable for use in async functions + const client = electricClient; + + let mounted = true; + syncKeyRef.current = syncKey; + + async function startSync() { + try { + const handle = await client.syncShape({ + table: "chat_comments", + where: `thread_id = ${threadId}`, + columns: ["id", "message_id", "thread_id", "parent_id", "author_id", "content", "created_at", "updated_at"], + primaryKey: ["id"], + }); + + if (!handle.isUpToDate && handle.initialSyncPromise) { + try { + await Promise.race([ + handle.initialSyncPromise, + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); + } catch { + // Initial sync timeout - continue anyway + } + } + + if (!mounted) { + handle.unsubscribe(); + return; + } + + syncHandleRef.current = handle; + + // Fetch initial comments and update cache + await fetchAndUpdateCache(); + + // Set up live query for real-time updates + await setupLiveQuery(); + } catch { + // Sync failed - will retry on next mount + } + } + + async function fetchAndUpdateCache() { + try { + const result = await client.db.query( + `SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at + FROM chat_comments + WHERE thread_id = $1 + ORDER BY created_at ASC`, + [threadId] + ); + + if (mounted && result.rows) { + updateReactQueryCache(result.rows); + } + } catch { + // Query failed - data will be fetched from API + } + } + + 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, message_id, thread_id, parent_id, author_id, content, created_at, updated_at + FROM chat_comments + WHERE thread_id = $1 + ORDER BY created_at ASC`, + [threadId] + ); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + // Set initial results + if (liveQuery.initialResults?.rows) { + updateReactQueryCache(liveQuery.initialResults.rows); + } else if (liveQuery.rows) { + updateReactQueryCache(liveQuery.rows); + } + + // Subscribe to changes + if (typeof liveQuery.subscribe === "function") { + liveQuery.subscribe((result: { rows: RawCommentRow[] }) => { + if (mounted && result.rows) { + updateReactQueryCache(result.rows); + } + }); + } + + if (typeof liveQuery.unsubscribe === "function") { + liveQueryRef.current = liveQuery; + } + } + } catch { + // Live query setup failed - will use initial fetch only + } + } + + 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; + } + }; + }, [threadId, electricClient, updateReactQueryCache]); +} diff --git a/surfsense_web/hooks/use-comments-live.ts b/surfsense_web/hooks/use-comments-live.ts deleted file mode 100644 index f4d922888..000000000 --- a/surfsense_web/hooks/use-comments-live.ts +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { useShape } from "@electric-sql/react"; -import { useAtomValue } from "jotai"; -import { useMemo } from "react"; -import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import type { Comment, CommentReply, Author } from "@/contracts/types/chat-comments.types"; -import type { Membership } from "@/contracts/types/members.types"; -import type { RawComment } from "@/contracts/types/chat-comments.types"; - -const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; - -// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN) -const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g; - -/** - * Member info for building author objects - derived from Membership - */ -type MemberInfo = Pick; - -/** - * Render mentions in content by replacing @[uuid] with @{DisplayName} - */ -function renderMentions(content: string, memberMap: Map): string { - return content.replace(MENTION_PATTERN, (match, uuid) => { - const member = memberMap.get(uuid); - if (member?.user_display_name) { - return `@{${member.user_display_name}}`; - } - return match; - }); -} - -/** - * Hook to get live comments for a specific message. - * Uses Electric SQL for comments + membersAtom (API) for author info. - * Returns data matching the existing Comment type. - */ -export function useCommentsLive(messageId: number | null) { - const { - data: commentsData, - isLoading: commentsLoading, - isError: commentsError, - error: commentsErrorDetails, - } = useShape({ - url: `${ELECTRIC_URL}/v1/shape`, - params: { - table: "chat_comments", - where: `message_id = ${messageId}`, - }, - }); - - const { data: membersData, isLoading: membersLoading } = useAtomValue(membersAtom); - const { data: currentUser } = useAtomValue(currentUserAtom); - const { data: myAccess } = useAtomValue(myAccessAtom); - - const comments = useMemo(() => { - if (!commentsData) return []; - - // Build member lookup map - const memberMap = new Map(); - if (membersData) { - for (const member of membersData) { - memberMap.set(member.user_id, { - user_display_name: member.user_display_name, - user_avatar_url: member.user_avatar_url, - user_email: member.user_email, - }); - } - } - - const currentUserId = currentUser?.id; - const isOwnerOrAdmin = myAccess?.is_owner ?? false; - - // Build author object from member data - const buildAuthor = (authorId: string | null): Author | null => { - if (!authorId) return null; - const member = memberMap.get(authorId); - if (!member) return null; - return { - id: authorId, - display_name: member.user_display_name ?? null, - avatar_url: member.user_avatar_url ?? null, - email: member.user_email ?? "", - }; - }; - - // Transform raw comment to CommentReply - const transformToReply = (raw: RawComment): CommentReply => { - const isEdited = raw.created_at !== raw.updated_at; - const isAuthor = currentUserId === raw.author_id; - - return { - id: raw.id, - content: raw.content, - content_rendered: renderMentions(raw.content, memberMap), - author: buildAuthor(raw.author_id), - created_at: raw.created_at, - updated_at: raw.updated_at, - is_edited: isEdited, - can_edit: isAuthor, - can_delete: isAuthor || isOwnerOrAdmin, - }; - }; - - // Separate top-level comments and replies - const topLevelRaw: RawComment[] = []; - const repliesMap = new Map(); - - for (const raw of commentsData) { - if (raw.parent_id === null) { - topLevelRaw.push(raw); - } else { - const replies = repliesMap.get(raw.parent_id) || []; - replies.push(raw); - repliesMap.set(raw.parent_id, replies); - } - } - - // Transform top-level comments to Comment type - const transformToComment = (raw: RawComment): Comment => { - const isEdited = raw.created_at !== raw.updated_at; - const isAuthor = currentUserId === raw.author_id; - const rawReplies = repliesMap.get(raw.id) || []; - const replies = rawReplies.map(transformToReply); - - return { - id: raw.id, - message_id: raw.message_id, - content: raw.content, - content_rendered: renderMentions(raw.content, memberMap), - author: buildAuthor(raw.author_id), - created_at: raw.created_at, - updated_at: raw.updated_at, - is_edited: isEdited, - can_edit: isAuthor, - can_delete: isAuthor || isOwnerOrAdmin, - reply_count: replies.length, - replies, - }; - }; - - return topLevelRaw.map(transformToComment); - }, [commentsData, membersData, currentUser?.id, myAccess?.is_owner]); - - return { - comments, - commentCount: commentsData?.length ?? 0, - isLoading: commentsLoading || membersLoading, - isError: commentsError, - error: commentsError ? commentsErrorDetails : null, - }; -} From 12437f840a656a65a358bce7c1f6be270aebd99d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 18:43:20 +0200 Subject: [PATCH 40/53] Add new_chat_messages table to PGlite and create useMessagesElectric hook --- .../contracts/types/chat-messages.types.ts | 1 - surfsense_web/hooks/use-chat-messages-live.ts | 82 ---------- surfsense_web/hooks/use-messages-electric.ts | 154 ++++++++++++++++++ surfsense_web/lib/electric/client.ts | 15 ++ 4 files changed, 169 insertions(+), 83 deletions(-) delete mode 100644 surfsense_web/hooks/use-chat-messages-live.ts create mode 100644 surfsense_web/hooks/use-messages-electric.ts diff --git a/surfsense_web/contracts/types/chat-messages.types.ts b/surfsense_web/contracts/types/chat-messages.types.ts index faba71bff..78bf7b043 100644 --- a/surfsense_web/contracts/types/chat-messages.types.ts +++ b/surfsense_web/contracts/types/chat-messages.types.ts @@ -10,7 +10,6 @@ export const rawMessage = z.object({ content: z.unknown(), author_id: z.string().nullable(), created_at: z.string(), - updated_at: z.string(), }); export type RawMessage = z.infer; diff --git a/surfsense_web/hooks/use-chat-messages-live.ts b/surfsense_web/hooks/use-chat-messages-live.ts deleted file mode 100644 index 4a8ae97e6..000000000 --- a/surfsense_web/hooks/use-chat-messages-live.ts +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useShape } from "@electric-sql/react"; -import { useAtomValue } from "jotai"; -import { useMemo } from "react"; -import { membersAtom } from "@/atoms/members/members-query.atoms"; -import type { RawMessage } from "@/contracts/types/chat-messages.types"; -import type { Membership } from "@/contracts/types/members.types"; -import type { MessageRecord } from "@/lib/chat/thread-persistence"; - -const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; - -/** - * Member info for building author data - derived from Membership - */ -type MemberInfo = Pick; - -/** - * Hook to get live chat messages for real-time sync. - * Uses Electric SQL for messages + membersAtom (API) for author info. - */ -export function useChatMessagesLive(threadId: number | null) { - - const { - data: messagesData, - isLoading: messagesLoading, - isError: messagesError, - error: messagesErrorDetails, - } = useShape({ - url: `${ELECTRIC_URL}/v1/shape`, - params: { - table: "new_chat_messages", - where: `thread_id = ${threadId}`, - }, - }); - - - const { data: membersData, isLoading: membersLoading } = useAtomValue(membersAtom); - - - const messages = useMemo(() => { - if (!messagesData) return []; - - // Build member lookup map - const memberMap = new Map(); - if (membersData) { - for (const member of membersData) { - memberMap.set(member.user_id, { - user_display_name: member.user_display_name, - user_avatar_url: member.user_avatar_url, - }); - } - } - - // Transform raw messages to MessageRecord with author info - return [...messagesData].map((msg): MessageRecord => { - const author = msg.author_id ? memberMap.get(msg.author_id) : null; - - const role = (typeof msg.role === "string" ? msg.role.toLowerCase() : msg.role) as - | "user" - | "assistant" - | "system"; - return { - id: msg.id, - thread_id: msg.thread_id, - role, - content: msg.content, - created_at: msg.created_at, - author_id: msg.author_id, - author_display_name: author?.user_display_name ?? null, - author_avatar_url: author?.user_avatar_url ?? null, - }; - }); - }, [messagesData, membersData]); - - return { - messages, - isLoading: messagesLoading || membersLoading, - isError: messagesError, - error: messagesError ? messagesErrorDetails : null, - }; -} diff --git a/surfsense_web/hooks/use-messages-electric.ts b/surfsense_web/hooks/use-messages-electric.ts new file mode 100644 index 000000000..e8c82e92b --- /dev/null +++ b/surfsense_web/hooks/use-messages-electric.ts @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import type { RawMessage } from "@/contracts/types/chat-messages.types"; +import type { SyncHandle } from "@/lib/electric/client"; +import { useElectricClient } from "@/lib/electric/context"; + +/** + * Syncs chat messages for a thread via Electric SQL. + * Calls onMessagesUpdate when messages change. + */ +export function useMessagesElectric( + threadId: number | null, + onMessagesUpdate: (messages: RawMessage[]) => void +) { + const electricClient = useElectricClient(); + + const syncHandleRef = useRef(null); + const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); + const syncKeyRef = useRef(null); + const onMessagesUpdateRef = useRef(onMessagesUpdate); + + useEffect(() => { + onMessagesUpdateRef.current = onMessagesUpdate; + }, [onMessagesUpdate]); + + const handleMessagesUpdate = useCallback((rows: RawMessage[]) => { + onMessagesUpdateRef.current(rows); + }, []); + + useEffect(() => { + if (!threadId || !electricClient) { + return; + } + + const syncKey = `messages_${threadId}`; + if (syncKeyRef.current === syncKey) { + return; + } + + const client = electricClient; + let mounted = true; + syncKeyRef.current = syncKey; + + async function startSync() { + try { + const handle = await client.syncShape({ + table: "new_chat_messages", + where: `thread_id = ${threadId}`, + columns: ["id", "thread_id", "role", "content", "author_id", "created_at"], + primaryKey: ["id"], + }); + + if (!handle.isUpToDate && handle.initialSyncPromise) { + try { + await Promise.race([ + handle.initialSyncPromise, + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); + } catch { + // Timeout + } + } + + if (!mounted) { + handle.unsubscribe(); + return; + } + + syncHandleRef.current = handle; + await fetchMessages(); + await setupLiveQuery(); + } catch { + // Sync failed + } + } + + async function fetchMessages() { + try { + const result = await client.db.query( + `SELECT id, thread_id, role, content, author_id, created_at + FROM new_chat_messages + WHERE thread_id = $1 + ORDER BY created_at ASC`, + [threadId] + ); + + if (mounted && result.rows) { + handleMessagesUpdate(result.rows); + } + } catch { + // Query failed + } + } + + 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, thread_id, role, content, author_id, created_at + FROM new_chat_messages + WHERE thread_id = $1 + ORDER BY created_at ASC`, + [threadId] + ); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + if (liveQuery.initialResults?.rows) { + handleMessagesUpdate(liveQuery.initialResults.rows); + } else if (liveQuery.rows) { + handleMessagesUpdate(liveQuery.rows); + } + + if (typeof liveQuery.subscribe === "function") { + liveQuery.subscribe((result: { rows: RawMessage[] }) => { + if (mounted && result.rows) { + handleMessagesUpdate(result.rows); + } + }); + } + + if (typeof liveQuery.unsubscribe === "function") { + liveQueryRef.current = liveQuery; + } + } + } catch { + // Live query failed + } + } + + 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; + } + }; + }, [threadId, electricClient, handleMessagesUpdate]); +} diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 84df6c905..479026120 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -258,6 +258,21 @@ export async function initElectric(userId: string): Promise { CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id); `); + // Create new_chat_messages table for live message sync + await db.exec(` + CREATE TABLE IF NOT EXISTS new_chat_messages ( + id INTEGER PRIMARY KEY, + thread_id INTEGER NOT NULL, + role TEXT NOT NULL, + content JSONB NOT NULL, + author_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_new_chat_messages_thread_id ON new_chat_messages(thread_id); + CREATE INDEX IF NOT EXISTS idx_new_chat_messages_created_at ON new_chat_messages(created_at); + `); + const electricUrl = getElectricUrl(); // STEP 4: Create the client wrapper From 4cb835f19fd4708f7a0ab8687de394d3809df6a6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 18:43:32 +0200 Subject: [PATCH 41/53] Integrate live message sync for shared chat collaboration --- .../new-chat/[[...chat_id]]/page.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) 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 43c33ba5a..cb6e797bd 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 @@ -24,6 +24,7 @@ import { // extractWriteTodosFromContent, hydratePlanStateAtom, } from "@/atoms/chat/plan-state.atom"; +import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Thread } from "@/components/assistant-ui/thread"; import { ChatHeader } from "@/components/new-chat/chat-header"; @@ -49,6 +50,8 @@ import { type MessageRecord, type ThreadRecord, } from "@/lib/chat/thread-persistence"; +import { useChatSessionState } from "@/hooks/use-chat-session-state"; +import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { trackChatCreated, trackChatError, @@ -257,6 +260,59 @@ export default function NewChatPage() { // Get current user for author info in shared chats const { data: currentUser } = useAtomValue(currentUserAtom); + // Live collaboration: sync messages from other users via Electric SQL + const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); + const { data: membersData } = useAtomValue(membersAtom); + + const handleElectricMessagesUpdate = useCallback( + (electricMessages: { id: number; thread_id: number; role: string; content: unknown; author_id: string | null; created_at: string }[]) => { + // Skip sync while AI is responding to us or during local streaming + if ((isAiResponding && respondingToUserId === currentUser?.id) || isRunning) { + return; + } + + setMessages((prev) => { + const existingIds = new Set( + prev.map((m) => { + const match = String(m.id).match(/^msg-(\d+)$/); + return match ? Number.parseInt(match[1], 10) : null; + }).filter((id): id is number => id !== null) + ); + + const newConverted: ReturnType[] = []; + for (const msg of electricMessages) { + if (existingIds.has(msg.id)) continue; + + const member = msg.author_id + ? membersData?.find((m) => m.user_id === msg.author_id) + : null; + + newConverted.push( + convertToThreadMessage({ + id: msg.id, + thread_id: msg.thread_id, + role: msg.role.toLowerCase() as "user" | "assistant" | "system", + content: msg.content, + author_id: msg.author_id, + created_at: msg.created_at, + author_display_name: member?.user_display_name ?? null, + author_avatar_url: member?.user_avatar_url ?? null, + }) + ); + } + + if (newConverted.length === 0) { + return prev; + } + + return [...prev, ...newConverted]; + }); + }, + [isAiResponding, respondingToUserId, currentUser?.id, isRunning, membersData] + ); + + useMessagesElectric(threadId, handleElectricMessagesUpdate); + // Create the attachment adapter for file processing const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); From 39d434b00c32ef366a693926e40063cc503697ac Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 19:04:23 +0200 Subject: [PATCH 42/53] Centralize chat session state sync with single subscription via atom --- .../new-chat/[[...chat_id]]/page.tsx | 10 ++++-- .../atoms/chat/chat-session-state.atom.ts | 15 ++++++++ .../components/assistant-ui/thread.tsx | 6 ++-- surfsense_web/hooks/use-chat-session-state.ts | 35 ++++++++++++------- 4 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 surfsense_web/atoms/chat/chat-session-state.atom.ts 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 cb6e797bd..e76b83a97 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 @@ -50,7 +50,8 @@ import { type MessageRecord, type ThreadRecord, } from "@/lib/chat/thread-persistence"; -import { useChatSessionState } from "@/hooks/use-chat-session-state"; +import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; +import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { trackChatCreated, @@ -260,8 +261,11 @@ export default function NewChatPage() { // Get current user for author info in shared chats const { data: currentUser } = useAtomValue(currentUserAtom); - // Live collaboration: sync messages from other users via Electric SQL - const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); + // Live collaboration: sync session state and messages via Electric SQL + useChatSessionStateSync(threadId); + const sessionState = useAtomValue(chatSessionStateAtom); + const isAiResponding = sessionState?.isAiResponding ?? false; + const respondingToUserId = sessionState?.respondingToUserId ?? null; const { data: membersData } = useAtomValue(membersAtom); const handleElectricMessagesUpdate = useCallback( diff --git a/surfsense_web/atoms/chat/chat-session-state.atom.ts b/surfsense_web/atoms/chat/chat-session-state.atom.ts new file mode 100644 index 000000000..4d83a45d4 --- /dev/null +++ b/surfsense_web/atoms/chat/chat-session-state.atom.ts @@ -0,0 +1,15 @@ +"use client"; + +import { atom } from "jotai"; + +export interface ChatSessionStateData { + threadId: number; + isAiResponding: boolean; + respondingToUserId: string | null; +} + +/** + * Global chat session state atom. + * Updated by useChatSessionStateSync hook, read anywhere. + */ +export const chatSessionStateAtom = atom(null); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index e1169867c..e419258f2 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -61,7 +61,7 @@ import { import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import type { Document } from "@/contracts/types/document.types"; -import { useChatSessionState } from "@/hooks/use-chat-session-state"; +import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { cn } from "@/lib/utils"; @@ -236,7 +236,9 @@ const Composer: FC = () => { } return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null; }, [chat_id]); - const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); + const sessionState = useAtomValue(chatSessionStateAtom); + const isAiResponding = sessionState?.isAiResponding ?? false; + const respondingToUserId = sessionState?.respondingToUserId ?? null; const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; // Sync comments for the entire thread via Electric SQL (one subscription per thread) diff --git a/surfsense_web/hooks/use-chat-session-state.ts b/surfsense_web/hooks/use-chat-session-state.ts index fb263826f..f3bdd7722 100644 --- a/surfsense_web/hooks/use-chat-session-state.ts +++ b/surfsense_web/hooks/use-chat-session-state.ts @@ -1,30 +1,39 @@ "use client"; import { useShape } from "@electric-sql/react"; +import { useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import type { ChatSessionState } from "@/contracts/types/chat-session-state.types"; const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; /** - * Hook to get live chat session state for collaboration. - * Tracks which user the AI is currently responding to. + * Syncs chat session state for a thread via Electric SQL. + * Call once per thread (in page.tsx). Updates global atom. */ -export function useChatSessionState(threadId: number | null) { - const { data, isLoading, isError, error } = useShape({ +export function useChatSessionStateSync(threadId: number | null) { + const setSessionState = useSetAtom(chatSessionStateAtom); + + const { data } = useShape({ url: `${ELECTRIC_URL}/v1/shape`, params: { table: "chat_session_state", - where: `thread_id = ${threadId}`, + where: `thread_id = ${threadId ?? -1}`, }, }); - const sessionState = data?.[0] ?? null; + useEffect(() => { + if (!threadId) { + setSessionState(null); + return; + } - return { - sessionState, - isAiResponding: !!sessionState?.ai_responding_to_user_id, - respondingToUserId: sessionState?.ai_responding_to_user_id ?? null, - loading: isLoading, - error: isError ? error : null, - }; + const row = data?.[0]; + setSessionState({ + threadId, + isAiResponding: !!row?.ai_responding_to_user_id, + respondingToUserId: row?.ai_responding_to_user_id ?? null, + }); + }, [threadId, data, setSessionState]); } From 076de2f3d71255748b736ad6000a612444a3ad50 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:56:02 +0530 Subject: [PATCH 43/53] feat: add unread count endpoint and update inbox logic - Introduced a new API endpoint to fetch unread notification counts, providing both total and recent counts for better tracking. - Updated the useInbox hook to manage separate states for older and recent unread counts, enhancing user experience with real-time updates. - Implemented logic to handle marking notifications as read, ensuring accurate count adjustments for both recent and older items. - Enhanced type definitions in the inbox types to support the new unread count functionality. --- .../app/routes/notifications_routes.py | 68 +++++- surfsense_web/contracts/types/inbox.types.ts | 18 ++ surfsense_web/hooks/use-inbox.ts | 200 +++++++++++++++--- .../lib/apis/notifications-api.service.ts | 20 ++ 4 files changed, 268 insertions(+), 38 deletions(-) diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index 6172aacc5..6bc945643 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -5,7 +5,7 @@ Electric SQL automatically syncs the changes to all connected clients for recent For older items (beyond the sync window), use the list endpoint. """ -from datetime import datetime +from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel @@ -17,6 +17,9 @@ from app.users import current_active_user router = APIRouter(prefix="/notifications", tags=["notifications"]) +# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts +SYNC_WINDOW_DAYS = 14 + class NotificationResponse(BaseModel): """Response model for a single notification.""" @@ -60,11 +63,68 @@ class MarkAllReadResponse(BaseModel): updated_count: int +class UnreadCountResponse(BaseModel): + """Response for unread count with split between recent and older items.""" + + total_unread: int + recent_unread: int # Within SYNC_WINDOW_DAYS + + +@router.get("/unread-count", response_model=UnreadCountResponse) +async def get_unread_count( + search_space_id: int | None = Query(None, description="Filter by search space ID"), + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> UnreadCountResponse: + """ + Get the total unread notification count for the current user. + + Returns both: + - total_unread: All unread notifications (for accurate badge count) + - recent_unread: Unread notifications within the sync window (last 14 days) + + This allows the frontend to calculate: + - older_unread = total_unread - recent_unread (static until reconciliation) + - Display count = older_unread + live_recent_count (from Electric SQL) + """ + # Calculate cutoff date for sync window + cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS) + + # Base filter for user's unread notifications + base_filter = [ + Notification.user_id == user.id, + Notification.read == False, # noqa: E712 + ] + + # Add search space filter if provided (include null for global notifications) + if search_space_id is not None: + base_filter.append( + (Notification.search_space_id == search_space_id) + | (Notification.search_space_id.is_(None)) + ) + + # Total unread count (all time) + total_query = select(func.count(Notification.id)).where(*base_filter) + total_result = await session.execute(total_query) + total_unread = total_result.scalar() or 0 + + # Recent unread count (within sync window) + recent_query = select(func.count(Notification.id)).where( + *base_filter, + Notification.created_at > cutoff_date, + ) + recent_result = await session.execute(recent_query) + recent_unread = recent_result.scalar() or 0 + + return UnreadCountResponse( + total_unread=total_unread, + recent_unread=recent_unread, + ) + + @router.get("", response_model=NotificationListResponse) async def list_notifications( - search_space_id: int | None = Query( - None, description="Filter by search space ID" - ), + search_space_id: int | None = Query(None, description="Filter by search space ID"), type_filter: str | None = Query( None, alias="type", description="Filter by notification type" ), diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 12ebfe1e9..0983bbc55 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -184,6 +184,22 @@ export const markAllNotificationsReadResponse = z.object({ updated_count: z.number(), }); +/** + * Request schema for getting unread count + */ +export const getUnreadCountRequest = z.object({ + search_space_id: z.number().optional(), +}); + +/** + * Response schema for unread count + * Returns both total and recent counts for split tracking + */ +export const getUnreadCountResponse = z.object({ + total_unread: z.number(), + recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days) +}); + // ============================================================================= // Type Guards for Metadata // ============================================================================= @@ -261,3 +277,5 @@ export type GetNotificationsResponse = z.infer; export type MarkNotificationReadRequest = z.infer; export type MarkNotificationReadResponse = z.infer; export type MarkAllNotificationsReadResponse = z.infer; +export type GetUnreadCountRequest = z.infer; +export type GetUnreadCountResponse = z.infer; diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 7f0bd59ef..4c26ddcb9 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import type { SyncHandle } from "@/lib/electric/client"; @@ -11,6 +11,15 @@ export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types const PAGE_SIZE = 50; const SYNC_WINDOW_DAYS = 14; +/** + * Check if an item is older than the sync window + */ +function isOlderThanSyncWindow(createdAt: string): boolean { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS); + return new Date(createdAt) < cutoffDate; +} + /** * Deduplicate by ID and sort by created_at descending. * This is the SINGLE source of truth for deduplication - prevents race conditions. @@ -84,15 +93,19 @@ export function useInbox( const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); + // Split unread count tracking for accurate counts with 14-day sync window + // olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation) + // recentUnreadCount = unread items within sync window (from live query, real-time) + const [olderUnreadCount, setOlderUnreadCount] = useState(0); + const [recentUnreadCount, setRecentUnreadCount] = useState(0); + const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const userSyncKeyRef = useRef(null); + const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - // Calculate unread count from inboxItems (includes both recent and older when loaded) - // This ensures the count is always in sync with what's displayed - const totalUnreadCount = useMemo(() => { - return inboxItems.filter((item) => !item.read).length; - }, [inboxItems]); + // Total unread = older (static from server) + recent (live from Electric) + const totalUnreadCount = olderUnreadCount + recentUnreadCount; // EFFECT 1: Electric SQL sync for real-time updates useEffect(() => { @@ -167,6 +180,9 @@ export function useInbox( useEffect(() => { setHasMore(true); setInboxItems([]); + // Reset count states - will be refetched by the unread count effect + setOlderUnreadCount(0); + setRecentUnreadCount(0); }, [userId, searchSpaceId, typeFilter]); // EFFECT 2: Live query for real-time updates + auto-fetch from API if empty @@ -283,6 +299,97 @@ export function useInbox( }; }, [userId, searchSpaceId, typeFilter, electricClient]); + // EFFECT 3: Dedicated unread count sync with split tracking + // - Fetches server count on mount (accurate total) + // - Sets up live query for recent count (real-time updates) + // - Handles items older than sync window separately + useEffect(() => { + if (!userId || !electricClient) return; + + const client = electricClient; + let mounted = true; + + async function setupUnreadCountSync() { + // Cleanup previous live query + if (unreadCountLiveQueryRef.current) { + unreadCountLiveQueryRef.current.unsubscribe(); + unreadCountLiveQueryRef.current = null; + } + + try { + // STEP 1: Fetch server counts (total and recent) - guaranteed accurate + console.log("[useInbox] Fetching unread count from server"); + const serverCounts = await notificationsApiService.getUnreadCount( + searchSpaceId ?? undefined + ); + + if (mounted) { + // Calculate older count = total - recent + const olderCount = serverCounts.total_unread - serverCounts.recent_unread; + setOlderUnreadCount(olderCount); + setRecentUnreadCount(serverCounts.recent_unread); + console.log( + `[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}` + ); + } + + // STEP 2: Set up PGLite live query for RECENT unread count only + // This provides real-time updates for notifications within sync window + const db = client.db as any; + const cutoff = getSyncCutoffDate(); + + // Count query - NO LIMIT, counts all unread in synced window + const countQuery = ` + SELECT COUNT(*) as count FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL) + AND created_at > '${cutoff}' + AND read = false + ${typeFilter ? "AND type = $3" : ""} + `; + const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; + + if (db.live?.query) { + const liveQuery = await db.live.query(countQuery, params); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + if (liveQuery.subscribe) { + liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { + if (mounted && result.rows?.[0]) { + const liveCount = Number(result.rows[0].count) || 0; + // Update recent count from live query + // This fires in real-time when Electric syncs new/updated notifications + setRecentUnreadCount(liveCount); + } + }); + } + + if (liveQuery.unsubscribe) { + unreadCountLiveQueryRef.current = liveQuery; + } + } + } catch (err) { + console.error("[useInbox] Unread count sync error:", err); + // On error, counts will remain at 0 or previous values + // The items-based count will be the fallback + } + } + + setupUnreadCountSync(); + + return () => { + mounted = false; + if (unreadCountLiveQueryRef.current) { + unreadCountLiveQueryRef.current.unsubscribe(); + unreadCountLiveQueryRef.current = null; + } + }; + }, [userId, searchSpaceId, typeFilter, electricClient]); + // loadMore - Pure cursor-based pagination, no race conditions // Cursor is computed from current state, not stored in refs const loadMore = useCallback(async () => { @@ -325,39 +432,60 @@ export function useInbox( }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); // Mark inbox item as read with optimistic update - const markAsRead = useCallback(async (itemId: number) => { - // Optimistic update: mark as read immediately for instant UI feedback - setInboxItems((prev) => - prev.map((item) => (item.id === itemId ? { ...item, read: true } : item)) - ); + // Handles both recent items (live query updates count) and older items (manual count decrement) + const markAsRead = useCallback( + async (itemId: number) => { + // Find the item to check if it's older than sync window + const item = inboxItems.find((i) => i.id === itemId); + const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at); - try { - // Use the API service with proper Zod validation - const result = await notificationsApiService.markAsRead({ notificationId: itemId }); + // Optimistic update: mark as read immediately for instant UI feedback + setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i))); - if (!result.success) { - // Rollback on error - setInboxItems((prev) => - prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) - ); + // If older item, manually decrement older count + // (live query won't see items outside sync window) + if (isOlderItem) { + setOlderUnreadCount((prev) => Math.max(0, prev - 1)); } - // If successful, Electric SQL will sync the change and live query will update - // This ensures eventual consistency even if optimistic update was wrong - return result.success; - } catch (err) { - console.error("Failed to mark as read:", err); - // Rollback on error - setInboxItems((prev) => - prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) - ); - return false; - } - }, []); + + try { + // Use the API service with proper Zod validation + const result = await notificationsApiService.markAsRead({ notificationId: itemId }); + + if (!result.success) { + // Rollback on error + setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); + if (isOlderItem) { + setOlderUnreadCount((prev) => prev + 1); + } + } + // If successful, Electric SQL will sync the change and live query will update + // This ensures eventual consistency even if optimistic update was wrong + return result.success; + } catch (err) { + console.error("Failed to mark as read:", err); + // Rollback on error + setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); + if (isOlderItem) { + setOlderUnreadCount((prev) => prev + 1); + } + return false; + } + }, + [inboxItems] + ); // Mark all inbox items as read with optimistic update + // Resets both older and recent counts to 0 const markAllAsRead = useCallback(async () => { + // Store previous counts for potential rollback + const prevOlderCount = olderUnreadCount; + const prevRecentCount = recentUnreadCount; + // Optimistic update: mark all as read immediately for instant UI feedback setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); + setOlderUnreadCount(0); + setRecentUnreadCount(0); try { // Use the API service with proper Zod validation @@ -365,16 +493,20 @@ export function useInbox( if (!result.success) { console.error("Failed to mark all as read"); - // On error, let Electric SQL sync correct the state + // Rollback counts on error + setOlderUnreadCount(prevOlderCount); + setRecentUnreadCount(prevRecentCount); } // Electric SQL will sync and live query will ensure consistency return result.success; } catch (err) { console.error("Failed to mark all as read:", err); - // On error, let Electric SQL sync correct the state + // Rollback counts on error + setOlderUnreadCount(prevOlderCount); + setRecentUnreadCount(prevRecentCount); return false; } - }, []); + }, [olderUnreadCount, recentUnreadCount]); return { inboxItems, diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index aff220f17..927aee747 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -1,11 +1,13 @@ import { type GetNotificationsRequest, type GetNotificationsResponse, + type GetUnreadCountResponse, type MarkAllNotificationsReadResponse, type MarkNotificationReadRequest, type MarkNotificationReadResponse, getNotificationsRequest, getNotificationsResponse, + getUnreadCountResponse, markAllNotificationsReadResponse, markNotificationReadRequest, markNotificationReadResponse, @@ -85,6 +87,24 @@ class NotificationsApiService { markAllAsRead = async (): Promise => { return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse); }; + + /** + * Get unread notification count with split between total and recent + * - total_unread: All unread notifications + * - recent_unread: Unread within sync window (last 14 days) + */ + getUnreadCount = async (searchSpaceId?: number): Promise => { + const params = new URLSearchParams(); + if (searchSpaceId !== undefined) { + params.append("search_space_id", String(searchSpaceId)); + } + const queryString = params.toString(); + + return baseApiService.get( + `/api/v1/notifications/unread-count${queryString ? `?${queryString}` : ""}`, + getUnreadCountResponse + ); + }; } export const notificationsApiService = new NotificationsApiService(); From 6c5223e2621131b63b7f3282aaf93b3f98c204da Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Thu, 22 Jan 2026 12:26:37 -0500 Subject: [PATCH 44/53] Made numerous minor layout changes to main layout and sidebar --- .../dashboard/[search_space_id]/layout.tsx | 6 ---- .../components/dashboard-breadcrumb.tsx | 5 +-- .../layout/providers/LayoutDataProvider.tsx | 8 +---- .../components/layout/ui/sidebar/Sidebar.tsx | 10 +++--- .../layout/ui/sidebar/SidebarHeader.tsx | 8 ++++- .../components/new-chat/model-selector.tsx | 33 +++++++++++-------- surfsense_web/messages/en.json | 1 + surfsense_web/messages/zh.json | 1 + 8 files changed, 36 insertions(+), 36 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx index 1631f00b9..e434d85fb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx @@ -39,12 +39,6 @@ export default function DashboardLayout({ icon: "SquareLibrary", items: [], }, - { - title: "Logs", - url: `/dashboard/${search_space_id}/logs`, - icon: "Logs", - items: [], - }, ]; return ( diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 0e9374fdd..223c4764a 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -76,9 +76,6 @@ export function DashboardBreadcrumb() { const segments = path.split("/").filter(Boolean); const breadcrumbs: BreadcrumbItemInterface[] = []; - // Always start with Dashboard - breadcrumbs.push({ label: t("dashboard"), href: "/dashboard" }); - // Handle search space if (segments[0] === "dashboard" && segments[1]) { // Use the actual search space name if available, otherwise fall back to the ID @@ -182,7 +179,7 @@ export function DashboardBreadcrumb() { const breadcrumbs = generateBreadcrumbs(pathname); - if (breadcrumbs.length <= 1) { + if (breadcrumbs.length === 0) { return null; // Don't show breadcrumbs for root dashboard } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 7f55e295f..f382aa395 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { LogOut, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -151,12 +151,6 @@ export function LayoutDataProvider({ icon: SquareLibrary, isActive: pathname?.includes("/documents"), }, - { - title: "Logs", - url: `/dashboard/${searchSpaceId}/logs`, - icon: Logs, - isActive: pathname?.includes("/logs"), - }, ], [searchSpaceId, pathname] ); diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 9a632506c..d87bf61e6 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -95,6 +95,11 @@ export function Sidebar({
)} + {/* Platform navigation */} + {navItems.length > 0 && ( + + )} + {/* New chat button */}
{isCollapsed ? ( @@ -115,11 +120,6 @@ export function Sidebar({ )}
- {/* Platform navigation */} - {navItems.length > 0 && ( - - )} - {/* Scrollable content */} {isCollapsed ? ( diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx index 6ca057819..4ed212b47 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx @@ -1,6 +1,7 @@ "use client"; -import { ChevronsUpDown, Settings, Users } from "lucide-react"; +import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { @@ -29,6 +30,7 @@ export function SidebarHeader({ className, }: SidebarHeaderProps) { const t = useTranslations("sidebar"); + const router = useRouter(); return (
@@ -52,6 +54,10 @@ export function SidebarHeader({ {t("manage_members")} + router.push(`/dashboard/${searchSpace?.id}/logs`)}> + + {t("logs")} + diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index ead378e86..5693c0b15 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -124,6 +124,11 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp ); }, [userConfigs, searchQuery]); + // Total model count for conditional search display + const totalModels = useMemo(() => { + return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0); + }, [globalConfigs, userConfigs]); + const handleSelectConfig = useCallback( async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => { // If already selected, just close @@ -230,15 +235,17 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
)} -
- -
+ {totalModels > 3 && ( +
+ +
+ )} @@ -264,7 +271,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp value={`global-${config.id}`} onSelect={() => handleSelectConfig(config)} className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer", + "mx-2 rounded-lg mb-1 cursor-pointer group", "aria-selected:bg-accent/50", isSelected && "bg-accent/80" )} @@ -295,7 +302,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
)} - {/* Platform navigation */} - {navItems.length > 0 && ( - - )} - {/* New chat button */}
{isCollapsed ? ( @@ -231,7 +226,12 @@ export function Sidebar({ {/* Footer */} -
+
+ {/* Platform navigation */} + {navItems.length > 0 && ( + + )} + {pageUsage && !isCollapsed && ( )} From b1b63c674045b1b8cc15bdeed0fcfc3925b56413 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 20:40:49 +0200 Subject: [PATCH 46/53] Simplify live message sync using Electric as source of truth --- .../new-chat/[[...chat_id]]/page.tsx | 55 ++++++------------- 1 file changed, 17 insertions(+), 38 deletions(-) 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 e76b83a97..2d1c0900c 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 @@ -50,7 +50,6 @@ import { type MessageRecord, type ThreadRecord, } from "@/lib/chat/thread-persistence"; -import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { @@ -263,56 +262,38 @@ export default function NewChatPage() { // Live collaboration: sync session state and messages via Electric SQL useChatSessionStateSync(threadId); - const sessionState = useAtomValue(chatSessionStateAtom); - const isAiResponding = sessionState?.isAiResponding ?? false; - const respondingToUserId = sessionState?.respondingToUserId ?? null; const { data: membersData } = useAtomValue(membersAtom); const handleElectricMessagesUpdate = useCallback( (electricMessages: { id: number; thread_id: number; role: string; content: unknown; author_id: string | null; created_at: string }[]) => { - // Skip sync while AI is responding to us or during local streaming - if ((isAiResponding && respondingToUserId === currentUser?.id) || isRunning) { + if (isRunning) { return; } setMessages((prev) => { - const existingIds = new Set( - prev.map((m) => { - const match = String(m.id).match(/^msg-(\d+)$/); - return match ? Number.parseInt(match[1], 10) : null; - }).filter((id): id is number => id !== null) - ); - - const newConverted: ReturnType[] = []; - for (const msg of electricMessages) { - if (existingIds.has(msg.id)) continue; + if (electricMessages.length < prev.length) { + return prev; + } + return electricMessages.map((msg) => { const member = msg.author_id ? membersData?.find((m) => m.user_id === msg.author_id) : null; - newConverted.push( - convertToThreadMessage({ - id: msg.id, - thread_id: msg.thread_id, - role: msg.role.toLowerCase() as "user" | "assistant" | "system", - content: msg.content, - author_id: msg.author_id, - created_at: msg.created_at, - author_display_name: member?.user_display_name ?? null, - author_avatar_url: member?.user_avatar_url ?? null, - }) - ); - } - - if (newConverted.length === 0) { - return prev; - } - - return [...prev, ...newConverted]; + return convertToThreadMessage({ + id: msg.id, + thread_id: msg.thread_id, + role: msg.role.toLowerCase() as "user" | "assistant" | "system", + content: msg.content, + author_id: msg.author_id, + created_at: msg.created_at, + author_display_name: member?.user_display_name ?? null, + author_avatar_url: member?.user_avatar_url ?? null, + }); + }); }); }, - [isAiResponding, respondingToUserId, currentUser?.id, isRunning, membersData] + [isRunning, membersData] ); useMessagesElectric(threadId, handleElectricMessagesUpdate); @@ -646,8 +627,6 @@ export default function NewChatPage() { content: persistContent, }) .then(() => { - // For new threads, the backend updates the title from the first user message - // Invalidate threads query so sidebar shows the updated title in real-time if (isNewThread) { queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] }); } From 03445a5e07a78216dd128631f0af7ab88950a2ee Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 22 Jan 2026 20:54:09 +0200 Subject: [PATCH 47/53] Fix auto-scroll by explicitly enabling it on ThreadViewport The turnAnchor="top" setting caused autoScroll to default to false. --- surfsense_web/components/assistant-ui/thread.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index e419258f2..2127d4d1d 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -90,6 +90,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => { > Date: Thu, 22 Jan 2026 12:26:37 -0500 Subject: [PATCH 48/53] Made numerous minor layout changes to main layout and sidebar --- surfsense_web/components/layout/ui/sidebar/Sidebar.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index e121cd904..70ca04025 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -95,6 +95,11 @@ export function Sidebar({
)} + {/* Platform navigation */} + {navItems.length > 0 && ( + + )} + {/* New chat button */}
{isCollapsed ? ( From 1f05cf2854f2236de3712ccda4f341ab79d5d547 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Thu, 22 Jan 2026 12:41:49 -0500 Subject: [PATCH 49/53] Moved the Documents sidebar item to the bottom, above the page count --- surfsense_web/components/layout/ui/sidebar/Sidebar.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 70ca04025..e121cd904 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -95,11 +95,6 @@ export function Sidebar({
)} - {/* Platform navigation */} - {navItems.length > 0 && ( - - )} - {/* New chat button */}
{isCollapsed ? ( From ed931bb404f7c25c11c8dde39b43dcf5e4f090b9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 22 Jan 2026 16:04:08 -0800 Subject: [PATCH 50/53] fix: live comment sync in shared chats - Added a method to find existing notifications by mention ID to prevent duplicate notifications. - Updated notify_new_mention to check for existing notifications before creating a new one, ensuring idempotency. - Implemented error handling for race conditions during notification creation, allowing retrieval of existing notifications in case of duplicate key errors. - Improved documentation for clarity on the use of mention_id for idempotency. --- .../app/services/notification_service.py | 78 +++++++++++++++---- surfsense_web/hooks/use-comments-electric.ts | 34 ++++++++ 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py index 5f7f568f6..28807e783 100644 --- a/surfsense_backend/app/services/notification_service.py +++ b/surfsense_backend/app/services/notification_service.py @@ -623,6 +623,28 @@ class MentionNotificationHandler(BaseNotificationHandler): def __init__(self): super().__init__("new_mention") + async def find_notification_by_mention( + self, + session: AsyncSession, + mention_id: int, + ) -> Notification | None: + """ + Find an existing notification by mention ID. + + Args: + session: Database session + mention_id: The mention ID to search for + + Returns: + Notification if found, None otherwise + """ + query = select(Notification).where( + Notification.type == self.notification_type, + Notification.notification_metadata["mention_id"].astext == str(mention_id), + ) + result = await session.execute(query) + return result.scalar_one_or_none() + async def notify_new_mention( self, session: AsyncSession, @@ -641,11 +663,12 @@ class MentionNotificationHandler(BaseNotificationHandler): ) -> Notification: """ Create notification when a user is @mentioned in a comment. + Uses mention_id for idempotency to prevent duplicate notifications. Args: session: Database session mentioned_user_id: User who was mentioned - mention_id: ID of the mention record + mention_id: ID of the mention record (used for idempotency) comment_id: ID of the comment containing the mention message_id: ID of the message being commented on thread_id: ID of the chat thread @@ -658,8 +681,16 @@ class MentionNotificationHandler(BaseNotificationHandler): search_space_id: Search space ID Returns: - Notification: The created notification + Notification: The created or existing notification """ + # Check if notification already exists for this mention (idempotency) + existing = await self.find_notification_by_mention(session, mention_id) + if existing: + logger.info( + f"Notification already exists for mention {mention_id}, returning existing" + ) + return existing + title = f"{author_name} mentioned you" message = content_preview[:100] + ("..." if len(content_preview) > 100 else "") @@ -676,21 +707,34 @@ class MentionNotificationHandler(BaseNotificationHandler): "content_preview": content_preview[:200], } - notification = Notification( - user_id=mentioned_user_id, - search_space_id=search_space_id, - type=self.notification_type, - title=title, - message=message, - notification_metadata=metadata, - ) - session.add(notification) - await session.commit() - await session.refresh(notification) - logger.info( - f"Created new_mention notification {notification.id} for user {mentioned_user_id}" - ) - return notification + try: + notification = Notification( + user_id=mentioned_user_id, + search_space_id=search_space_id, + type=self.notification_type, + title=title, + message=message, + notification_metadata=metadata, + ) + session.add(notification) + await session.commit() + await session.refresh(notification) + logger.info( + f"Created new_mention notification {notification.id} for user {mentioned_user_id}" + ) + return notification + except Exception as e: + # Handle race condition - if duplicate key error, try to fetch existing + await session.rollback() + if "duplicate key" in str(e).lower() or "unique constraint" in str(e).lower(): + logger.warning( + f"Duplicate notification detected for mention {mention_id}, fetching existing" + ) + existing = await self.find_notification_by_mention(session, mention_id) + if existing: + return existing + # Re-raise if not a duplicate key error or couldn't find existing + raise class NotificationService: diff --git a/surfsense_web/hooks/use-comments-electric.ts b/surfsense_web/hooks/use-comments-electric.ts index 83a019ef3..e4eb27d4a 100644 --- a/surfsense_web/hooks/use-comments-electric.ts +++ b/surfsense_web/hooks/use-comments-electric.ts @@ -15,6 +15,9 @@ import type { SyncHandle } from "@/lib/electric/client"; import { useElectricClient } from "@/lib/electric/context"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +// Debounce delay for stream updates (ms) +const STREAM_UPDATE_DEBOUNCE_MS = 100; + // Raw comment from PGlite local database interface RawCommentRow { id: number; @@ -208,6 +211,7 @@ export function useCommentsElectric(threadId: number | null) { const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const syncKeyRef = useRef(null); + const streamUpdateDebounceRef = useRef | null>(null); // Stable callback that uses refs for fresh values const updateReactQueryCache = useCallback((rows: RawCommentRow[]) => { @@ -275,6 +279,30 @@ export function useCommentsElectric(threadId: number | null) { // Set up live query for real-time updates await setupLiveQuery(); + + // Subscribe to the sync stream for real-time updates from Electric SQL + // This ensures we catch updates even if PGlite live query misses them + if (handle.stream) { + const stream = handle.stream as { subscribe?: (callback: (messages: unknown[]) => void) => void }; + if (typeof stream.subscribe === "function") { + stream.subscribe((messages: unknown[]) => { + if (!mounted) return; + // When Electric sync receives new data, refresh from PGlite + // This handles cases where live query might miss the update + if (messages && messages.length > 0) { + // Debounce the refresh to avoid excessive queries + if (streamUpdateDebounceRef.current) { + clearTimeout(streamUpdateDebounceRef.current); + } + streamUpdateDebounceRef.current = setTimeout(() => { + if (mounted) { + fetchAndUpdateCache(); + } + }, STREAM_UPDATE_DEBOUNCE_MS); + } + }); + } + } } catch { // Sync failed - will retry on next mount } @@ -348,6 +376,12 @@ export function useCommentsElectric(threadId: number | null) { mounted = false; syncKeyRef.current = null; + // Clear debounce timeout + if (streamUpdateDebounceRef.current) { + clearTimeout(streamUpdateDebounceRef.current); + streamUpdateDebounceRef.current = null; + } + if (syncHandleRef.current) { syncHandleRef.current.unsubscribe(); syncHandleRef.current = null; From 8dc1ae8b6cbaccf361c51063963b02e4a4b06714 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Thu, 22 Jan 2026 19:29:29 -0500 Subject: [PATCH 51/53] fix: use searchSpaceId from params to prevent malformed URL in Logs navigation --- .../components/layout/ui/sidebar/SidebarHeader.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx index 4ed212b47..5c8c89152 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { @@ -31,6 +31,8 @@ export function SidebarHeader({ }: SidebarHeaderProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const params = useParams(); + const searchSpaceId = params.search_space_id as string; return (
@@ -54,7 +56,7 @@ export function SidebarHeader({ {t("manage_members")} - router.push(`/dashboard/${searchSpace?.id}/logs`)}> + router.push(`/dashboard/${searchSpaceId}/logs`)}> {t("logs")} From eb509810f1930eb50c18b2823e0308173b335928 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 22 Jan 2026 16:35:28 -0800 Subject: [PATCH 52/53] fix(temp-hotpatch): Disabling composio until fixed. --- .../assistant-ui/connector-popup/tabs/all-connectors-tab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 1b36b3b81..55c66c392 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -155,7 +155,7 @@ export const AllConnectorsTab: FC = ({ )} {/* Composio Integrations */} - {filteredComposio.length > 0 && onOpenComposio && ( + {/* {filteredComposio.length > 0 && onOpenComposio && (

Managed OAuth

@@ -176,7 +176,7 @@ export const AllConnectorsTab: FC = ({ ))}
- )} + )} */} {/* More Integrations */} {filteredOther.length > 0 && ( From 8b815077397037cf04d8a24d4c9657e5f762488d Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 22 Jan 2026 16:43:08 -0800 Subject: [PATCH 53/53] refactor: remove unused COMPOSIO_CONNECTOR migration and linting --- .../74_add_composio_connector_enums.py | 81 ------------ .../alembic/versions/74_no_op.py | 29 +++++ .../77_add_thread_id_to_chat_comments.py | 4 +- .../app/connectors/composio_connector.py | 6 +- .../app/connectors/github_connector.py | 46 ++++--- surfsense_backend/app/db.py | 4 +- .../app/routes/composio_routes.py | 30 +++-- .../app/services/composio_service.py | 114 ++++++++++------ .../app/services/notification_service.py | 5 +- .../app/tasks/composio_indexer.py | 123 +++++++++++++----- .../app/tasks/connector_indexers/__init__.py | 1 + .../connector_indexers/github_indexer.py | 14 +- .../new-chat/[[...chat_id]]/page.tsx | 17 ++- .../assistant-ui/chat-session-status.tsx | 5 +- .../assistant-ui/connector-popup.tsx | 10 +- .../connector-configs/index.tsx | 2 +- .../tabs/all-connectors-tab.tsx | 9 +- .../views/composio-toolkit-view.tsx | 102 +++++++++++---- .../components/assistant-ui/thread.tsx | 2 +- .../layout/ui/sidebar/InboxSidebar.tsx | 6 +- .../layout/ui/sidebar/SidebarUserProfile.tsx | 2 +- surfsense_web/components/ui/drawer.tsx | 2 +- surfsense_web/hooks/use-comments-electric.ts | 32 +++-- .../lib/apis/notifications-api.service.ts | 6 +- 24 files changed, 401 insertions(+), 251 deletions(-) delete mode 100644 surfsense_backend/alembic/versions/74_add_composio_connector_enums.py create mode 100644 surfsense_backend/alembic/versions/74_no_op.py diff --git a/surfsense_backend/alembic/versions/74_add_composio_connector_enums.py b/surfsense_backend/alembic/versions/74_add_composio_connector_enums.py deleted file mode 100644 index 454b60754..000000000 --- a/surfsense_backend/alembic/versions/74_add_composio_connector_enums.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Add COMPOSIO_CONNECTOR to SearchSourceConnectorType and DocumentType enums - -Revision ID: 74 -Revises: 73 -Create Date: 2026-01-21 - -This migration adds the COMPOSIO_CONNECTOR enum value to both: -- searchsourceconnectortype (for connector type tracking) -- documenttype (for document type tracking) - -Composio is a managed OAuth integration service that allows connecting -to various third-party services (Google Drive, Gmail, Calendar, etc.) -without requiring separate OAuth app verification. -""" - -from collections.abc import Sequence - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "74" -down_revision: str | None = "73" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - -# Define the ENUM type names and the new value -CONNECTOR_ENUM = "searchsourceconnectortype" -CONNECTOR_NEW_VALUE = "COMPOSIO_CONNECTOR" -DOCUMENT_ENUM = "documenttype" -DOCUMENT_NEW_VALUE = "COMPOSIO_CONNECTOR" - - -def upgrade() -> None: - """Upgrade schema - add COMPOSIO_CONNECTOR to connector and document enums safely.""" - # Add COMPOSIO_CONNECTOR to searchsourceconnectortype only if not exists - op.execute( - f""" - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_enum - WHERE enumlabel = '{CONNECTOR_NEW_VALUE}' - AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{CONNECTOR_ENUM}') - ) THEN - ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}'; - END IF; - END$$; - """ - ) - - # Add COMPOSIO_CONNECTOR to documenttype only if not exists - op.execute( - f""" - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_enum - WHERE enumlabel = '{DOCUMENT_NEW_VALUE}' - AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{DOCUMENT_ENUM}') - ) THEN - ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}'; - END IF; - END$$; - """ - ) - - -def downgrade() -> None: - """Downgrade schema - remove COMPOSIO_CONNECTOR from connector and document enums. - - Note: PostgreSQL does not support removing enum values directly. - To properly downgrade, you would need to: - 1. Delete any rows using the COMPOSIO_CONNECTOR value - 2. Create new enums without COMPOSIO_CONNECTOR - 3. Alter the columns to use the new enums - 4. Drop the old enums - - This is left as a no-op since removing enum values is complex - and typically not needed in practice. - """ - pass diff --git a/surfsense_backend/alembic/versions/74_no_op.py b/surfsense_backend/alembic/versions/74_no_op.py new file mode 100644 index 000000000..a5ee99b29 --- /dev/null +++ b/surfsense_backend/alembic/versions/74_no_op.py @@ -0,0 +1,29 @@ +"""No-op migration for Composio support + +Revision ID: 74 +Revises: 73 +Create Date: 2026-01-21 + +NOTE: This migration is a no-op since Composio is not supported yet. +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "74" +down_revision: str | None = "73" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """No-op upgrade for Composio support.""" + pass + + +def downgrade() -> None: + """No-op downgrade for Composio support. + + Note: PostgreSQL does not support removing enum values directly. + """ + pass diff --git a/surfsense_backend/alembic/versions/77_add_thread_id_to_chat_comments.py b/surfsense_backend/alembic/versions/77_add_thread_id_to_chat_comments.py index 0a2615e84..86886eacf 100644 --- a/surfsense_backend/alembic/versions/77_add_thread_id_to_chat_comments.py +++ b/surfsense_backend/alembic/versions/77_add_thread_id_to_chat_comments.py @@ -64,5 +64,7 @@ def upgrade() -> None: def downgrade() -> None: """Remove thread_id column from chat_comments.""" op.execute("DROP INDEX IF EXISTS idx_chat_comments_thread_id") - op.execute("ALTER TABLE chat_comments DROP CONSTRAINT IF EXISTS fk_chat_comments_thread_id") + op.execute( + "ALTER TABLE chat_comments DROP CONSTRAINT IF EXISTS fk_chat_comments_thread_id" + ) op.execute("ALTER TABLE chat_comments DROP COLUMN IF EXISTS thread_id") diff --git a/surfsense_backend/app/connectors/composio_connector.py b/surfsense_backend/app/connectors/composio_connector.py index 18fd9564c..fdf57d8ea 100644 --- a/surfsense_backend/app/connectors/composio_connector.py +++ b/surfsense_backend/app/connectors/composio_connector.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.db import SearchSourceConnector -from app.services.composio_service import ComposioService, INDEXABLE_TOOLKITS +from app.services.composio_service import INDEXABLE_TOOLKITS, ComposioService logger = logging.getLogger(__name__) @@ -268,7 +268,9 @@ class ComposioConnector: from_email = header_dict.get("from", "Unknown Sender") to_email = header_dict.get("to", "Unknown Recipient") # Composio provides messageTimestamp directly - date_str = message.get("messageTimestamp", "") or header_dict.get("date", "Unknown Date") + date_str = message.get("messageTimestamp", "") or header_dict.get( + "date", "Unknown Date" + ) # Build markdown content markdown_content = f"# {subject}\n\n" diff --git a/surfsense_backend/app/connectors/github_connector.py b/surfsense_backend/app/connectors/github_connector.py index 6f04ccdba..9d4b98c4b 100644 --- a/surfsense_backend/app/connectors/github_connector.py +++ b/surfsense_backend/app/connectors/github_connector.py @@ -58,7 +58,9 @@ class GitHubConnector: if self.token: logger.info("GitHub connector initialized with authentication token.") else: - logger.info("GitHub connector initialized without token (public repos only).") + logger.info( + "GitHub connector initialized without token (public repos only)." + ) def ingest_repository( self, @@ -95,17 +97,27 @@ class GitHubConnector: cmd = [ "gitingest", repo_url, - "--output", output_path, - "--max-size", str(max_file_size), + "--output", + output_path, + "--max-size", + str(max_file_size), # Common exclude patterns - "-e", "node_modules/*", - "-e", "vendor/*", - "-e", ".git/*", - "-e", "__pycache__/*", - "-e", "dist/*", - "-e", "build/*", - "-e", "*.lock", - "-e", "package-lock.json", + "-e", + "node_modules/*", + "-e", + "vendor/*", + "-e", + ".git/*", + "-e", + "__pycache__/*", + "-e", + "dist/*", + "-e", + "build/*", + "-e", + "*.lock", + "-e", + "package-lock.json", ] # Add branch if specified @@ -147,7 +159,9 @@ class GitHubConnector: os.unlink(output_path) if not full_content or not full_content.strip(): - logger.warning(f"No content retrieved from repository: {repo_full_name}") + logger.warning( + f"No content retrieved from repository: {repo_full_name}" + ) return None # Parse the gitingest output @@ -171,11 +185,11 @@ class GitHubConnector: logger.error(f"gitingest timed out for repository: {repo_full_name}") return None except FileNotFoundError: - logger.error( - "gitingest CLI not found. Falling back to Python library." - ) + logger.error("gitingest CLI not found. Falling back to Python library.") # Fall back to Python library - return self._ingest_with_python_library(repo_full_name, branch, max_file_size) + return self._ingest_with_python_library( + repo_full_name, branch, max_file_size + ) except Exception as e: logger.error(f"Failed to ingest repository {repo_full_name}: {e}") return None diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 4b9be6f4a..db04009d2 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -82,7 +82,9 @@ class SearchSourceConnectorType(str, Enum): BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR" CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR" MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools - COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.) + COMPOSIO_CONNECTOR = ( + "COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.) + ) class LiteLLMProvider(str, Enum): diff --git a/surfsense_backend/app/routes/composio_routes.py b/surfsense_backend/app/routes/composio_routes.py index b6f418aa2..eecbaf598 100644 --- a/surfsense_backend/app/routes/composio_routes.py +++ b/surfsense_backend/app/routes/composio_routes.py @@ -10,7 +10,6 @@ Endpoints: - GET /auth/composio/connector/callback - Handle OAuth callback """ -import asyncio import logging from uuid import UUID @@ -85,7 +84,9 @@ async def list_composio_toolkits(user: User = Depends(current_active_user)): @router.get("/auth/composio/connector/add") async def initiate_composio_auth( space_id: int, - toolkit_id: str = Query(..., description="Composio toolkit ID (e.g., 'googledrive', 'gmail')"), + toolkit_id: str = Query( + ..., description="Composio toolkit ID (e.g., 'googledrive', 'gmail')" + ), user: User = Depends(current_active_user), ): """ @@ -166,7 +167,9 @@ async def initiate_composio_auth( @router.get("/auth/composio/connector/callback") async def composio_callback( state: str | None = None, - connectedAccountId: str | None = None, # Composio sends camelCase + composio_connected_account_id: str | None = Query( + None, alias="connectedAccountId" + ), # Composio sends camelCase connected_account_id: str | None = None, # Fallback snake_case error: str | None = None, session: AsyncSession = Depends(get_async_session), @@ -233,15 +236,18 @@ async def composio_callback( ) # Initialize Composio service - service = ComposioService() - entity_id = f"surfsense_{user_id}" - + ComposioService() + # Use camelCase param if provided (Composio's format), fallback to snake_case - final_connected_account_id = connectedAccountId or connected_account_id - + final_connected_account_id = ( + composio_connected_account_id or connected_account_id + ) + # DEBUG: Log all query parameters received - logger.info(f"DEBUG: Callback received - connectedAccountId: {connectedAccountId}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}") - + logger.info( + f"DEBUG: Callback received - connectedAccountId: {composio_connected_account_id}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}" + ) + # If we still don't have a connected_account_id, warn but continue # (the connector will be created but indexing won't work until updated) if not final_connected_account_id: @@ -250,7 +256,9 @@ async def composio_callback( "The connector will be created but indexing may not work." ) else: - logger.info(f"Successfully got connected_account_id: {final_connected_account_id}") + logger.info( + f"Successfully got connected_account_id: {final_connected_account_id}" + ) # Build connector config connector_config = { diff --git a/surfsense_backend/app/services/composio_service.py b/surfsense_backend/app/services/composio_service.py index 4b6a32b03..6046ea2d8 100644 --- a/surfsense_backend/app/services/composio_service.py +++ b/surfsense_backend/app/services/composio_service.py @@ -97,7 +97,7 @@ class ComposioService: config_toolkit = getattr(auth_config, "toolkit", None) if config_toolkit is None: continue - + # Extract toolkit name/slug from the object toolkit_name = None if isinstance(config_toolkit, str): @@ -108,18 +108,22 @@ class ComposioService: toolkit_name = config_toolkit.name elif hasattr(config_toolkit, "id"): toolkit_name = config_toolkit.id - + # Compare case-insensitively if toolkit_name and toolkit_name.lower() == toolkit_id.lower(): - logger.info(f"Found auth config {auth_config.id} for toolkit {toolkit_id}") + logger.info( + f"Found auth config {auth_config.id} for toolkit {toolkit_id}" + ) return auth_config.id - + # Log available auth configs for debugging - logger.warning(f"No auth config found for toolkit '{toolkit_id}'. Available auth configs:") + logger.warning( + f"No auth config found for toolkit '{toolkit_id}'. Available auth configs:" + ) for auth_config in auth_configs.items: config_toolkit = getattr(auth_config, "toolkit", None) logger.warning(f" - {auth_config.id}: toolkit={config_toolkit}") - + return None except Exception as e: logger.error(f"Failed to list auth configs: {e!s}") @@ -148,7 +152,7 @@ class ComposioService: try: # First, get the auth_config_id for this toolkit auth_config_id = self._get_auth_config_for_toolkit(toolkit_id) - + if not auth_config_id: raise ValueError( f"No auth config found for toolkit '{toolkit_id}'. " @@ -200,7 +204,9 @@ class ComposioService: "user_id": getattr(account, "user_id", None), } except Exception as e: - logger.error(f"Failed to get connected account {connected_account_id}: {e!s}") + logger.error( + f"Failed to get connected account {connected_account_id}: {e!s}" + ) return None async def list_all_connections(self) -> list[dict[str, Any]]: @@ -212,15 +218,17 @@ class ComposioService: """ try: accounts_response = self.client.connected_accounts.list() - + if hasattr(accounts_response, "items"): accounts = accounts_response.items elif hasattr(accounts_response, "__iter__"): accounts = accounts_response else: - logger.warning(f"Unexpected accounts response type: {type(accounts_response)}") + logger.warning( + f"Unexpected accounts response type: {type(accounts_response)}" + ) return [] - + result = [] for acc in accounts: toolkit_raw = getattr(acc, "toolkit", None) @@ -234,14 +242,16 @@ class ComposioService: toolkit_info = toolkit_raw.name else: toolkit_info = str(toolkit_raw) - - result.append({ - "id": acc.id, - "status": getattr(acc, "status", None), - "toolkit": toolkit_info, - "user_id": getattr(acc, "user_id", None), - }) - + + result.append( + { + "id": acc.id, + "status": getattr(acc, "status", None), + "toolkit": toolkit_info, + "user_id": getattr(acc, "user_id", None), + } + ) + logger.info(f"DEBUG: Found {len(result)} TOTAL connections in Composio") return result except Exception as e: @@ -261,16 +271,18 @@ class ComposioService: try: logger.info(f"DEBUG: Calling connected_accounts.list(user_id='{user_id}')") accounts_response = self.client.connected_accounts.list(user_id=user_id) - + # Handle paginated response (may have .items attribute) or direct list if hasattr(accounts_response, "items"): accounts = accounts_response.items elif hasattr(accounts_response, "__iter__"): accounts = accounts_response else: - logger.warning(f"Unexpected accounts response type: {type(accounts_response)}") + logger.warning( + f"Unexpected accounts response type: {type(accounts_response)}" + ) return [] - + result = [] for acc in accounts: # Extract toolkit info - might be string or object @@ -285,13 +297,15 @@ class ComposioService: toolkit_info = toolkit_raw.name else: toolkit_info = toolkit_raw - - result.append({ - "id": acc.id, - "status": getattr(acc, "status", None), - "toolkit": toolkit_info, - }) - + + result.append( + { + "id": acc.id, + "status": getattr(acc, "status", None), + "toolkit": toolkit_info, + } + ) + logger.info(f"Found {len(result)} connections for user {user_id}: {result}") return result except Exception as e: @@ -383,18 +397,24 @@ class ComposioService: return [], None, result.get("error", "Unknown error") data = result.get("data", {}) - logger.info(f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}") - + logger.info( + f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}" + ) + # Handle nested response structure from Composio files = [] next_token = None if isinstance(data, dict): # Try direct access first, then nested files = data.get("files", []) or data.get("data", {}).get("files", []) - next_token = data.get("nextPageToken") or data.get("next_page_token") or data.get("data", {}).get("nextPageToken") + next_token = ( + data.get("nextPageToken") + or data.get("next_page_token") + or data.get("data", {}).get("nextPageToken") + ) elif isinstance(data, list): files = data - + logger.info(f"DEBUG: Extracted {len(files)} drive files") return files, next_token, None @@ -475,16 +495,22 @@ class ComposioService: return [], result.get("error", "Unknown error") data = result.get("data", {}) - logger.info(f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}") + logger.info( + f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}" + ) logger.info(f"DEBUG: Gmail full data: {data}") - + # Try different possible response structures messages = [] if isinstance(data, dict): - messages = data.get("messages", []) or data.get("data", {}).get("messages", []) or data.get("emails", []) + messages = ( + data.get("messages", []) + or data.get("data", {}).get("messages", []) + or data.get("emails", []) + ) elif isinstance(data, list): messages = data - + logger.info(f"DEBUG: Extracted {len(messages)} messages") return messages, None @@ -569,16 +595,22 @@ class ComposioService: return [], result.get("error", "Unknown error") data = result.get("data", {}) - logger.info(f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}") + logger.info( + f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}" + ) logger.info(f"DEBUG: Calendar full data: {data}") - + # Try different possible response structures events = [] if isinstance(data, dict): - events = data.get("items", []) or data.get("data", {}).get("items", []) or data.get("events", []) + events = ( + data.get("items", []) + or data.get("data", {}).get("items", []) + or data.get("events", []) + ) elif isinstance(data, list): events = data - + logger.info(f"DEBUG: Extracted {len(events)} calendar events") return events, None diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py index 28807e783..836daeb9e 100644 --- a/surfsense_backend/app/services/notification_service.py +++ b/surfsense_backend/app/services/notification_service.py @@ -726,7 +726,10 @@ class MentionNotificationHandler(BaseNotificationHandler): except Exception as e: # Handle race condition - if duplicate key error, try to fetch existing await session.rollback() - if "duplicate key" in str(e).lower() or "unique constraint" in str(e).lower(): + if ( + "duplicate key" in str(e).lower() + or "unique constraint" in str(e).lower() + ): logger.warning( f"Duplicate notification detected for mention {mention_id}, fetching existing" ) diff --git a/surfsense_backend/app/tasks/composio_indexer.py b/surfsense_backend/app/tasks/composio_indexer.py index 01d2cfce4..abb238924 100644 --- a/surfsense_backend/app/tasks/composio_indexer.py +++ b/surfsense_backend/app/tasks/composio_indexer.py @@ -144,7 +144,9 @@ async def index_composio_connector( # Get toolkit ID from config toolkit_id = connector.config.get("toolkit_id") if not toolkit_id: - error_msg = f"Composio connector {connector_id} has no toolkit_id configured" + error_msg = ( + f"Composio connector {connector_id} has no toolkit_id configured" + ) await task_logger.log_task_failure( log_entry, error_msg, {"error_type": "MissingToolkitId"} ) @@ -287,8 +289,14 @@ async def _index_composio_google_drive( try: # Handle both standard Google API and potential Composio variations file_id = file_info.get("id", "") or file_info.get("fileId", "") - file_name = file_info.get("name", "") or file_info.get("fileName", "") or "Untitled" - mime_type = file_info.get("mimeType", "") or file_info.get("mime_type", "") + file_name = ( + file_info.get("name", "") + or file_info.get("fileName", "") + or "Untitled" + ) + mime_type = file_info.get("mimeType", "") or file_info.get( + "mime_type", "" + ) if not file_id: documents_skipped += 1 @@ -309,12 +317,15 @@ async def _index_composio_google_drive( ) # Get file content - content, content_error = await composio_connector.get_drive_file_content( - file_id - ) + ( + content, + content_error, + ) = await composio_connector.get_drive_file_content(file_id) if content_error or not content: - logger.warning(f"Could not get content for file {file_name}: {content_error}") + logger.warning( + f"Could not get content for file {file_name}: {content_error}" + ) # Use metadata as content fallback markdown_content = f"# {file_name}\n\n" markdown_content += f"**File ID:** {file_id}\n" @@ -344,12 +355,19 @@ async def _index_composio_google_drive( "mime_type": mime_type, "document_type": "Google Drive File (Composio)", } - summary_content, summary_embedding = await generate_document_summary( + ( + summary_content, + summary_embedding, + ) = await generate_document_summary( markdown_content, user_llm, document_metadata ) else: - summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}" - summary_embedding = config.embedding_model_instance.embed(summary_content) + summary_content = ( + f"Google Drive File: {file_name}\n\nType: {mime_type}" + ) + summary_embedding = config.embedding_model_instance.embed( + summary_content + ) chunks = await create_document_chunks(markdown_content) @@ -382,12 +400,19 @@ async def _index_composio_google_drive( "mime_type": mime_type, "document_type": "Google Drive File (Composio)", } - summary_content, summary_embedding = await generate_document_summary( + ( + summary_content, + summary_embedding, + ) = await generate_document_summary( markdown_content, user_llm, document_metadata ) else: - summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}" - summary_embedding = config.embedding_model_instance.embed(summary_content) + summary_content = ( + f"Google Drive File: {file_name}\n\nType: {mime_type}" + ) + summary_embedding = config.embedding_model_instance.embed( + summary_content + ) chunks = await create_document_chunks(markdown_content) @@ -527,11 +552,15 @@ async def _index_composio_gmail( date_str = value # Format to markdown using the full message data - markdown_content = composio_connector.format_gmail_message_to_markdown(message) + markdown_content = composio_connector.format_gmail_message_to_markdown( + message + ) # Generate unique identifier unique_identifier_hash = generate_unique_identifier_hash( - DocumentType.COMPOSIO_CONNECTOR, f"gmail_{message_id}", search_space_id + DocumentType.COMPOSIO_CONNECTOR, + f"gmail_{message_id}", + search_space_id, ) content_hash = generate_content_hash(markdown_content, search_space_id) @@ -560,12 +589,19 @@ async def _index_composio_gmail( "sender": sender, "document_type": "Gmail Message (Composio)", } - summary_content, summary_embedding = await generate_document_summary( + ( + summary_content, + summary_embedding, + ) = await generate_document_summary( markdown_content, user_llm, document_metadata ) else: - summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}" - summary_embedding = config.embedding_model_instance.embed(summary_content) + summary_content = ( + f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}" + ) + summary_embedding = config.embedding_model_instance.embed( + summary_content + ) chunks = await create_document_chunks(markdown_content) @@ -600,12 +636,19 @@ async def _index_composio_gmail( "sender": sender, "document_type": "Gmail Message (Composio)", } - summary_content, summary_embedding = await generate_document_summary( + ( + summary_content, + summary_embedding, + ) = await generate_document_summary( markdown_content, user_llm, document_metadata ) else: - summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}" - summary_embedding = config.embedding_model_instance.embed(summary_content) + summary_content = ( + f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}" + ) + summary_embedding = config.embedding_model_instance.embed( + summary_content + ) chunks = await create_document_chunks(markdown_content) @@ -728,18 +771,24 @@ async def _index_composio_google_calendar( try: # Handle both standard Google API and potential Composio variations event_id = event.get("id", "") or event.get("eventId", "") - summary = event.get("summary", "") or event.get("title", "") or "No Title" + summary = ( + event.get("summary", "") or event.get("title", "") or "No Title" + ) if not event_id: documents_skipped += 1 continue # Format to markdown - markdown_content = composio_connector.format_calendar_event_to_markdown(event) + markdown_content = composio_connector.format_calendar_event_to_markdown( + event + ) # Generate unique identifier unique_identifier_hash = generate_unique_identifier_hash( - DocumentType.COMPOSIO_CONNECTOR, f"calendar_{event_id}", search_space_id + DocumentType.COMPOSIO_CONNECTOR, + f"calendar_{event_id}", + search_space_id, ) content_hash = generate_content_hash(markdown_content, search_space_id) @@ -772,14 +821,19 @@ async def _index_composio_google_calendar( "start_time": start_time, "document_type": "Google Calendar Event (Composio)", } - summary_content, summary_embedding = await generate_document_summary( + ( + summary_content, + summary_embedding, + ) = await generate_document_summary( markdown_content, user_llm, document_metadata ) else: summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}" if location: summary_content += f"\nLocation: {location}" - summary_embedding = config.embedding_model_instance.embed(summary_content) + summary_embedding = config.embedding_model_instance.embed( + summary_content + ) chunks = await create_document_chunks(markdown_content) @@ -814,14 +868,21 @@ async def _index_composio_google_calendar( "start_time": start_time, "document_type": "Google Calendar Event (Composio)", } - summary_content, summary_embedding = await generate_document_summary( + ( + summary_content, + summary_embedding, + ) = await generate_document_summary( markdown_content, user_llm, document_metadata ) else: - summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}" + summary_content = ( + f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}" + ) if location: summary_content += f"\nLocation: {location}" - summary_embedding = config.embedding_model_instance.embed(summary_content) + summary_embedding = config.embedding_model_instance.embed( + summary_content + ) chunks = await create_document_chunks(markdown_content) @@ -874,5 +935,7 @@ async def _index_composio_google_calendar( return documents_indexed, None except Exception as e: - logger.error(f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True) + logger.error( + f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True + ) return 0, f"Failed to index Google Calendar via Composio: {e!s}" diff --git a/surfsense_backend/app/tasks/connector_indexers/__init__.py b/surfsense_backend/app/tasks/connector_indexers/__init__.py index 35b5fde4c..8f25e6fdd 100644 --- a/surfsense_backend/app/tasks/connector_indexers/__init__.py +++ b/surfsense_backend/app/tasks/connector_indexers/__init__.py @@ -26,6 +26,7 @@ Available indexers: # Calendar and scheduling from .airtable_indexer import index_airtable_records from .bookstack_indexer import index_bookstack_pages + # Note: composio_indexer is imported directly in connector_tasks.py to avoid circular imports from .clickup_indexer import index_clickup_tasks from .confluence_indexer import index_confluence_pages diff --git a/surfsense_backend/app/tasks/connector_indexers/github_indexer.py b/surfsense_backend/app/tasks/connector_indexers/github_indexer.py index f16ee0156..4a8df4918 100644 --- a/surfsense_backend/app/tasks/connector_indexers/github_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/github_indexer.py @@ -128,7 +128,9 @@ async def index_github_repos( if github_pat: logger.info("Using GitHub PAT for authentication (private repos supported)") else: - logger.info("No GitHub PAT provided - only public repositories can be indexed") + logger.info( + "No GitHub PAT provided - only public repositories can be indexed" + ) # 3. Initialize GitHub connector with gitingest backend await task_logger.log_task_progress( @@ -308,9 +310,7 @@ async def _process_repository_digest( if existing_document: # Document exists - check if content has changed if existing_document.content_hash == content_hash: - logger.info( - f"Repository {repo_full_name} unchanged. Skipping." - ) + logger.info(f"Repository {repo_full_name} unchanged. Skipping.") return 0 else: logger.info( @@ -341,7 +341,7 @@ async def _process_repository_digest( summary_content = ( f"# Repository: {repo_full_name}\n\n" f"## File Structure\n\n{digest.tree}\n\n" - f"## File Contents (truncated)\n\n{digest.content[:MAX_DIGEST_CHARS - len(digest.tree) - 200]}..." + f"## File Contents (truncated)\n\n{digest.content[: MAX_DIGEST_CHARS - len(digest.tree) - 200]}..." ) summary_text, summary_embedding = await generate_document_summary( @@ -362,9 +362,7 @@ async def _process_repository_digest( # This preserves file-level granularity in search chunks_data = await create_document_chunks(digest.content) except Exception as chunk_err: - logger.error( - f"Failed to chunk repository {repo_full_name}: {chunk_err}" - ) + logger.error(f"Failed to chunk repository {repo_full_name}: {chunk_err}") # Fall back to a simpler chunking approach chunks_data = await _simple_chunk_content(digest.content) 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 c61dad660..4d43b7f64 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 @@ -33,7 +33,9 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; -import { SaveMemoryToolUI, RecallMemoryToolUI } from "@/components/tool-ui/user-memory"; +import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; +import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; +import { useMessagesElectric } from "@/hooks/use-messages-electric"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; import { getBearerToken } from "@/lib/auth-utils"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; @@ -51,8 +53,6 @@ import { type MessageRecord, type ThreadRecord, } from "@/lib/chat/thread-persistence"; -import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; -import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { trackChatCreated, trackChatError, @@ -266,7 +266,16 @@ export default function NewChatPage() { const { data: membersData } = useAtomValue(membersAtom); const handleElectricMessagesUpdate = useCallback( - (electricMessages: { id: number; thread_id: number; role: string; content: unknown; author_id: string | null; created_at: string }[]) => { + ( + electricMessages: { + id: number; + thread_id: number; + role: string; + content: unknown; + author_id: string | null; + created_at: string; + }[] + ) => { if (isRunning) { return; } diff --git a/surfsense_web/components/assistant-ui/chat-session-status.tsx b/surfsense_web/components/assistant-ui/chat-session-status.tsx index 62f7c33ce..88fea6b8c 100644 --- a/surfsense_web/components/assistant-ui/chat-session-status.tsx +++ b/surfsense_web/components/assistant-ui/chat-session-status.tsx @@ -1,7 +1,7 @@ "use client"; -import type { FC } from "react"; import { Loader2 } from "lucide-react"; +import type { FC } from "react"; import { cn } from "@/lib/utils"; interface ChatSessionStatusProps { @@ -32,7 +32,8 @@ export const ChatSessionStatus: FC = ({ } const respondingUser = members.find((m) => m.user_id === respondingToUserId); - const displayName = respondingUser?.user_display_name || respondingUser?.user_email || "another user"; + const displayName = + respondingUser?.user_display_name || respondingUser?.user_email || "another user"; return (
{ ) : viewingComposio && searchSpaceId ? ( c.connector_type === "COMPOSIO_CONNECTOR") - .map((c: SearchSourceConnector) => c.config?.toolkit_id as string) - .filter(Boolean) - } + connectedToolkits={(connectors || []) + .filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR") + .map((c: SearchSourceConnector) => c.config?.toolkit_id as string) + .filter(Boolean)} onBack={handleBackFromComposio} onConnectToolkit={handleConnectComposioToolkit} isConnecting={connectingComposioToolkit !== null} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx index a7a92597c..4884584e6 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx @@ -5,8 +5,8 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { BaiduSearchApiConfig } from "./components/baidu-search-api-config"; import { BookStackConfig } from "./components/bookstack-config"; import { CirclebackConfig } from "./components/circleback-config"; -import { ComposioConfig } from "./components/composio-config"; import { ClickUpConfig } from "./components/clickup-config"; +import { ComposioConfig } from "./components/composio-config"; import { ConfluenceConfig } from "./components/confluence-config"; import { DiscordConfig } from "./components/discord-config"; import { ElasticsearchConfig } from "./components/elasticsearch-config"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 55c66c392..c5f5448bd 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -3,9 +3,14 @@ import type { FC } from "react"; import { EnumConnectorName } from "@/contracts/enums/connector"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { ConnectorCard } from "../components/connector-card"; import { ComposioConnectorCard } from "../components/composio-connector-card"; -import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS, COMPOSIO_CONNECTORS } from "../constants/connector-constants"; +import { ConnectorCard } from "../components/connector-card"; +import { + COMPOSIO_CONNECTORS, + CRAWLERS, + OAUTH_CONNECTORS, + OTHER_CONNECTORS, +} from "../constants/connector-constants"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; /** diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx index 456835597..9c0bd7223 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx @@ -5,12 +5,12 @@ import { Calendar, Check, ExternalLink, + FileText, Github, + HardDrive, Loader2, Mail, - HardDrive, MessageSquare, - FileText, Zap, } from "lucide-react"; import Image from "next/image"; @@ -82,17 +82,65 @@ const getToolkitIcon = (toolkitId: string, className?: string) => { switch (toolkitId) { case "googledrive": - return ; + return ( + + ); case "gmail": - return ; + return ( + + ); case "googlecalendar": - return ; + return ( + + ); case "slack": - return ; + return ( + + ); case "notion": - return ; + return ( + + ); case "github": - return ; + return ( + + ); default: return ; } @@ -139,9 +187,7 @@ export const ComposioToolkitView: FC = ({ />
-

- Composio -

+

Composio

Connect 100+ apps with managed OAuth - no verification needed

@@ -165,12 +211,16 @@ export const ComposioToolkitView: FC = ({

Google Services

- + Indexable

- Connect Google services via Composio's verified OAuth app. Your data will be indexed and searchable. + Connect Google services via Composio's verified OAuth app. Your data will be + indexed and searchable.

{indexableToolkits.map((toolkit) => { @@ -201,16 +251,17 @@ export const ComposioToolkitView: FC = ({ {getToolkitIcon(toolkit.id, "size-5")}
{isConnected && ( - + Connected )}

{toolkit.name}

-

- {toolkit.description} -

+

{toolkit.description}

diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 2127d4d1d..042b932a4 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -26,6 +26,7 @@ import { import { useParams } from "next/navigation"; import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom"; import { mentionedDocumentIdsAtom, @@ -61,7 +62,6 @@ import { import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import type { Document } from "@/contracts/types/document.types"; -import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { cn } from "@/lib/utils"; diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 166d77eca..810e3a22e 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -41,14 +41,14 @@ import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import type { InboxItem } from "@/hooks/use-inbox"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { type ConnectorIndexingMetadata, - type NewMentionMetadata, isConnectorIndexingMetadata, isNewMentionMetadata, + type NewMentionMetadata, } from "@/contracts/types/inbox.types"; +import type { InboxItem } from "@/hooks/use-inbox"; +import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; /** diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 907fd89f8..982d88e8b 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react"; +import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; import { useTranslations } from "next-intl"; import { DropdownMenu, diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx index 015d6ac07..c92531d14 100644 --- a/surfsense_web/components/ui/drawer.tsx +++ b/surfsense_web/components/ui/drawer.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { Drawer as DrawerPrimitive } from "vaul"; import { cn } from "@/lib/utils"; diff --git a/surfsense_web/hooks/use-comments-electric.ts b/surfsense_web/hooks/use-comments-electric.ts index e4eb27d4a..b90f52dce 100644 --- a/surfsense_web/hooks/use-comments-electric.ts +++ b/surfsense_web/hooks/use-comments-electric.ts @@ -5,11 +5,7 @@ import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import type { - Comment, - CommentReply, - Author, -} from "@/contracts/types/chat-comments.types"; +import type { Author, Comment, CommentReply } from "@/contracts/types/chat-comments.types"; import type { Membership } from "@/contracts/types/members.types"; import type { SyncHandle } from "@/lib/electric/client"; import { useElectricClient } from "@/lib/electric/context"; @@ -123,7 +119,10 @@ function transformComments( isOwner: boolean ): Map { // Group comments by message_id - const byMessage = new Map }>(); + const byMessage = new Map< + number, + { topLevel: RawCommentRow[]; replies: Map } + >(); for (const raw of rawComments) { if (!byMessage.has(raw.message_id)) { @@ -176,10 +175,10 @@ function transformComments( /** * Hook for syncing comments with Electric SQL real-time sync. - * + * * Syncs ALL comments for a thread in ONE subscription, then updates * React Query cache for each message. This avoids N subscriptions for N messages. - * + * * @param threadId - The thread ID to sync comments for */ export function useCommentsElectric(threadId: number | null) { @@ -247,12 +246,21 @@ export function useCommentsElectric(threadId: number | null) { let mounted = true; syncKeyRef.current = syncKey; - async function startSync() { + async function startSync() { try { const handle = await client.syncShape({ table: "chat_comments", where: `thread_id = ${threadId}`, - columns: ["id", "message_id", "thread_id", "parent_id", "author_id", "content", "created_at", "updated_at"], + columns: [ + "id", + "message_id", + "thread_id", + "parent_id", + "author_id", + "content", + "created_at", + "updated_at", + ], primaryKey: ["id"], }); @@ -283,7 +291,9 @@ export function useCommentsElectric(threadId: number | null) { // Subscribe to the sync stream for real-time updates from Electric SQL // This ensures we catch updates even if PGlite live query misses them if (handle.stream) { - const stream = handle.stream as { subscribe?: (callback: (messages: unknown[]) => void) => void }; + const stream = handle.stream as { + subscribe?: (callback: (messages: unknown[]) => void) => void; + }; if (typeof stream.subscribe === "function") { stream.subscribe((messages: unknown[]) => { if (!mounted) return; diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index 927aee747..a9e81a81f 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -2,12 +2,12 @@ import { type GetNotificationsRequest, type GetNotificationsResponse, type GetUnreadCountResponse, - type MarkAllNotificationsReadResponse, - type MarkNotificationReadRequest, - type MarkNotificationReadResponse, getNotificationsRequest, getNotificationsResponse, getUnreadCountResponse, + type MarkAllNotificationsReadResponse, + type MarkNotificationReadRequest, + type MarkNotificationReadResponse, markAllNotificationsReadResponse, markNotificationReadRequest, markNotificationReadResponse,