diff --git a/surfsense_backend/app/agents/researcher/nodes.py b/surfsense_backend/app/agents/researcher/nodes.py
index 491840589..35857c004 100644
--- a/surfsense_backend/app/agents/researcher/nodes.py
+++ b/surfsense_backend/app/agents/researcher/nodes.py
@@ -492,6 +492,7 @@ async def fetch_documents_by_ids(
"CLICKUP_CONNECTOR": "ClickUp (Selected)",
"AIRTABLE_CONNECTOR": "Airtable (Selected)",
"LUMA_CONNECTOR": "Luma Events (Selected)",
+ "NOTE": "Notes (Selected)",
}
source_object = {
@@ -1162,6 +1163,33 @@ async def fetch_relevant_documents(
}
)
+ elif connector == "NOTE":
+ (
+ source_object,
+ notes_chunks,
+ ) = await connector_service.search_notes(
+ user_query=reformulated_query,
+ search_space_id=search_space_id,
+ top_k=top_k,
+ start_date=start_date,
+ end_date=end_date,
+ )
+
+ # Add to sources and raw documents
+ if source_object:
+ all_sources.append(source_object)
+ all_raw_documents.extend(notes_chunks)
+
+ # Stream found document count
+ if streaming_service and writer:
+ writer(
+ {
+ "yield_value": streaming_service.format_terminal_info_delta(
+ f"📝 Found {len(notes_chunks)} Notes related to your query"
+ )
+ }
+ )
+
except Exception as e:
logging.error("Error in search_airtable: %s", traceback.format_exc())
error_message = f"Error searching connector {connector}: {e!s}"
diff --git a/surfsense_backend/app/agents/researcher/utils.py b/surfsense_backend/app/agents/researcher/utils.py
index 3666d8b8a..ea3c99547 100644
--- a/surfsense_backend/app/agents/researcher/utils.py
+++ b/surfsense_backend/app/agents/researcher/utils.py
@@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str:
"LUMA_CONNECTOR": "✨",
"ELASTICSEARCH_CONNECTOR": "⚡",
"WEBCRAWLER_CONNECTOR": "🌐",
+ "NOTE": "📝",
}
return connector_emojis.get(connector_name, "🔎")
@@ -59,6 +60,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
"LUMA_CONNECTOR": "Luma",
"ELASTICSEARCH_CONNECTOR": "Elasticsearch",
"WEBCRAWLER_CONNECTOR": "Web Pages",
+ "NOTE": "Notes",
}
return connector_friendly_names.get(connector_name, connector_name)
diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index a959db5e6..d06217fc4 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -266,12 +266,27 @@ async def read_documents(
document_type=doc.document_type,
document_metadata=doc.document_metadata,
content=doc.content,
+ content_hash=doc.content_hash,
+ unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
+ updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
)
)
- return PaginatedResponse(items=api_documents, total=total)
+ # Calculate pagination info
+ actual_page = (
+ page if page is not None else (offset // page_size if page_size > 0 else 0)
+ )
+ has_more = (offset + len(api_documents)) < total if page_size > 0 else False
+
+ return PaginatedResponse(
+ items=api_documents,
+ total=total,
+ page=actual_page,
+ page_size=page_size,
+ has_more=has_more,
+ )
except HTTPException:
raise
except Exception as e:
@@ -385,12 +400,27 @@ async def search_documents(
document_type=doc.document_type,
document_metadata=doc.document_metadata,
content=doc.content,
+ content_hash=doc.content_hash,
+ unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
+ updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
)
)
- return PaginatedResponse(items=api_documents, total=total)
+ # Calculate pagination info
+ actual_page = (
+ page if page is not None else (offset // page_size if page_size > 0 else 0)
+ )
+ has_more = (offset + len(api_documents)) < total if page_size > 0 else False
+
+ return PaginatedResponse(
+ items=api_documents,
+ total=total,
+ page=actual_page,
+ page_size=page_size,
+ has_more=has_more,
+ )
except HTTPException:
raise
except Exception as e:
@@ -510,7 +540,10 @@ async def get_document_by_chunk_id(
document_type=document.document_type,
document_metadata=document.document_metadata,
content=document.content,
+ content_hash=document.content_hash,
+ unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
+ updated_at=document.updated_at,
search_space_id=document.search_space_id,
chunks=sorted_chunks,
)
@@ -559,7 +592,10 @@ async def read_document(
document_type=document.document_type,
document_metadata=document.document_metadata,
content=document.content,
+ content_hash=document.content_hash,
+ unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
+ updated_at=document.updated_at,
search_space_id=document.search_space_id,
)
except HTTPException:
@@ -614,7 +650,10 @@ async def update_document(
document_type=db_document.document_type,
document_metadata=db_document.document_metadata,
content=db_document.content,
+ content_hash=db_document.content_hash,
+ unique_identifier_hash=db_document.unique_identifier_hash,
created_at=db_document.created_at,
+ updated_at=db_document.updated_at,
search_space_id=db_document.search_space_id,
)
except HTTPException:
diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py
index 1baf52e85..a0e7b59c1 100644
--- a/surfsense_backend/app/routes/editor_routes.py
+++ b/surfsense_backend/app/routes/editor_routes.py
@@ -172,7 +172,7 @@ async def save_document(
blocknote_document = data.get("blocknote_document")
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
-
+
# Add type validation
if not isinstance(blocknote_document, list):
raise HTTPException(status_code=400, detail="blocknote_document must be a list")
diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py
index 99a12e803..5bb0a88a9 100644
--- a/surfsense_backend/app/routes/notes_routes.py
+++ b/surfsense_backend/app/routes/notes_routes.py
@@ -5,7 +5,7 @@ Notes routes for creating and managing BlockNote documents.
from datetime import UTC, datetime
from typing import Any
-from fastapi import APIRouter, Body, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -64,7 +64,6 @@ async def create_note(
content_hash = hashlib.sha256(request.title.encode()).hexdigest()
# Create document with NOTE type
- from app.config import config
document = Document(
search_space_id=search_space_id,
diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py
index fc83d24be..e1e8b9248 100644
--- a/surfsense_backend/app/schemas/documents.py
+++ b/surfsense_backend/app/schemas/documents.py
@@ -46,7 +46,10 @@ class DocumentRead(BaseModel):
document_type: DocumentType
document_metadata: dict
content: str # Changed to string to match frontend
+ content_hash: str
+ unique_identifier_hash: str | None
created_at: datetime
+ updated_at: datetime | None
search_space_id: int
model_config = ConfigDict(from_attributes=True)
@@ -61,3 +64,6 @@ class DocumentWithChunksRead(DocumentRead):
class PaginatedResponse[T](BaseModel):
items: list[T]
total: int
+ page: int
+ page_size: int
+ has_more: bool
diff --git a/surfsense_backend/app/services/connector_service.py b/surfsense_backend/app/services/connector_service.py
index cac1b7f47..61bc08b40 100644
--- a/surfsense_backend/app/services/connector_service.py
+++ b/surfsense_backend/app/services/connector_service.py
@@ -2360,6 +2360,75 @@ class ConnectorService:
return result_object, elasticsearch_docs
+ async def search_notes(
+ self,
+ user_query: str,
+ search_space_id: int,
+ top_k: int = 20,
+ start_date: datetime | None = None,
+ end_date: datetime | None = None,
+ ) -> tuple:
+ """
+ Search for Notes and return both the source information and langchain documents.
+
+ Uses combined chunk-level and document-level hybrid search with RRF fusion.
+
+ Args:
+ user_query: The user's query
+ search_space_id: The search space ID to search in
+ top_k: Maximum number of results to return
+ start_date: Optional start date for filtering documents by updated_at
+ end_date: Optional end date for filtering documents by updated_at
+
+ Returns:
+ tuple: (sources_info, langchain_documents)
+ """
+ notes_docs = await self._combined_rrf_search(
+ query_text=user_query,
+ search_space_id=search_space_id,
+ document_type="NOTE",
+ top_k=top_k,
+ start_date=start_date,
+ end_date=end_date,
+ )
+
+ # Early return if no results
+ if not notes_docs:
+ return {
+ "id": 51,
+ "name": "Notes",
+ "type": "NOTE",
+ "sources": [],
+ }, []
+
+ def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
+ return doc_info.get("title", "Untitled Note")
+
+ def _url_fn(_doc_info: dict[str, Any], _metadata: dict[str, Any]) -> str:
+ return "" # Notes don't have URLs
+
+ def _description_fn(
+ chunk: dict[str, Any], _doc_info: dict[str, Any], _metadata: dict[str, Any]
+ ) -> str:
+ return self._chunk_preview(chunk.get("content", ""), limit=200)
+
+ sources_list = self._build_chunk_sources_from_documents(
+ notes_docs,
+ title_fn=_title_fn,
+ url_fn=_url_fn,
+ description_fn=_description_fn,
+ )
+
+ # Create result object
+ result_object = {
+ "id": 51,
+ "name": "Notes",
+ "type": "NOTE",
+ "sources": sources_list,
+ }
+
+ return result_object, notes_docs
+
async def search_bookstack(
self,
user_query: str,
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
index 8ab5309f2..b9d4c3b95 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
@@ -3,6 +3,7 @@
import logging
from sqlalchemy import delete, select
+from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy.pool import NullPool
@@ -11,6 +12,7 @@ from app.celery_app import celery_app
from app.config import config
from app.db import Document
from app.services.llm_service import get_user_long_context_llm
+from app.services.task_logging_service import TaskLoggingService
from app.utils.blocknote_converter import convert_blocknote_to_markdown
from app.utils.document_converters import (
create_document_chunks,
@@ -53,21 +55,42 @@ def reindex_document_task(self, document_id: int, user_id: str):
async def _reindex_document(document_id: int, user_id: str):
"""Async function to reindex a document."""
async with get_celery_session_maker()() as session:
+ # First, get the document to get search_space_id for logging
+ result = await session.execute(
+ select(Document)
+ .options(selectinload(Document.chunks))
+ .where(Document.id == document_id)
+ )
+ document = result.scalars().first()
+
+ if not document:
+ logger.error(f"Document {document_id} not found")
+ return
+
+ # Initialize task logger
+ task_logger = TaskLoggingService(session, document.search_space_id)
+
+ # Log task start
+ log_entry = await task_logger.log_task_start(
+ task_name="document_reindex",
+ source="editor",
+ message=f"Starting reindex for document: {document.title}",
+ metadata={
+ "document_id": document_id,
+ "document_type": document.document_type.value,
+ "title": document.title,
+ "user_id": user_id,
+ },
+ )
+
try:
- # Get document
- result = await session.execute(
- select(Document)
- .options(selectinload(Document.chunks)) # Eagerly load chunks
- .where(Document.id == document_id)
- )
- document = result.scalars().first()
-
- if not document:
- logger.error(f"Document {document_id} not found")
- return
-
if not document.blocknote_document:
- logger.warning(f"Document {document_id} has no BlockNote content")
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Document {document_id} has no BlockNote content to reindex",
+ "No BlockNote content",
+ {"error_type": "NoBlockNoteContent"},
+ )
return
logger.info(f"Reindexing document {document_id} ({document.title})")
@@ -78,7 +101,12 @@ async def _reindex_document(document_id: int, user_id: str):
)
if not markdown_content:
- logger.error(f"Failed to convert document {document_id} to markdown")
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Failed to convert document {document_id} to markdown",
+ "Markdown conversion failed",
+ {"error_type": "ConversionError"},
+ )
return
# 2. Delete old chunks explicitly
@@ -118,9 +146,39 @@ async def _reindex_document(document_id: int, user_id: str):
await session.commit()
+ # Log success
+ await task_logger.log_task_success(
+ log_entry,
+ f"Successfully reindexed document: {document.title}",
+ {
+ "chunks_created": len(new_chunks),
+ "document_id": document_id,
+ },
+ )
+
logger.info(f"Successfully reindexed document {document_id}")
+ except SQLAlchemyError as db_error:
+ await session.rollback()
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Database error during reindex for document {document_id}",
+ str(db_error),
+ {"error_type": "SQLAlchemyError"},
+ )
+ logger.error(
+ f"Database error reindexing document {document_id}: {db_error}",
+ exc_info=True,
+ )
+ raise
+
except Exception as e:
await session.rollback()
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Failed to reindex document: {document.title}",
+ str(e),
+ {"error_type": type(e).__name__},
+ )
logger.error(f"Error reindexing document {document_id}: {e}", exc_info=True)
raise
diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
index 028c0efdc..82197921a 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
@@ -14,7 +14,6 @@ import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContain
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
-import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
@@ -224,7 +223,6 @@ export function DashboardClientLayout({
-
{/* Only show artifacts toggle on researcher page */}
{isResearcherPage && (
(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -116,25 +113,6 @@ export default function EditorPage() {
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
const errorMessage = errorData.detail || "Failed to fetch document";
-
- // Log fetch error
- try {
- await createLog({
- level: "ERROR",
- status: "FAILED",
- message: `Failed to fetch document: ${errorMessage}`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: documentId,
- error_type: "fetch_error",
- http_status: response.status,
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
throw new Error(errorMessage);
}
@@ -142,48 +120,13 @@ export default function EditorPage() {
// Check if blocknote_document exists
if (!data.blocknote_document) {
- const errorMsg = "This document does not have BlockNote content. Please re-upload the document to enable editing.";
-
- // Log missing BlockNote content
- try {
- await createLog({
- level: "WARNING",
- status: "FAILED",
- message: `Document ${documentId} does not have BlockNote content`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: documentId,
- error_type: "missing_blocknote_content",
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
+ const errorMsg =
+ "This document does not have BlockNote content. Please re-upload the document to enable editing.";
setError(errorMsg);
setLoading(false);
return;
}
- // Log successful fetch
- try {
- await createLog({
- level: "INFO",
- status: "SUCCESS",
- message: `Document ${documentId} loaded successfully`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: documentId,
- document_type: data.document_type,
- title: data.title,
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
@@ -191,24 +134,6 @@ export default function EditorPage() {
console.error("Error fetching document:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
-
- // Log general fetch error
- try {
- await createLog({
- level: "ERROR",
- status: "FAILED",
- message: `Error fetching document: ${errorMessage}`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: documentId,
- error_type: "fetch_exception",
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
setError(errorMessage);
} finally {
setLoading(false);
@@ -218,7 +143,7 @@ export default function EditorPage() {
if (documentId) {
fetchDocument();
}
- }, [documentId, params.search_space_id, isNewNote, searchSpaceId, createLog]);
+ }, [documentId, params.search_space_id, isNewNote]);
// Track changes to mark as unsaved
useEffect(() => {
@@ -280,49 +205,10 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
-
- // Log save error
- try {
- await createLog({
- level: "ERROR",
- status: "FAILED",
- message: `Failed to save new note: ${errorData.detail || "Unknown error"}`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: note.id,
- is_new_note: true,
- action: "save",
- http_status: response.status,
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
throw new Error(errorData.detail || "Failed to save document");
}
}
- // Log successful note creation
- try {
- await createLog({
- level: "INFO",
- status: "SUCCESS",
- message: `Note created successfully: ${title}`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: note.id,
- is_new_note: true,
- action: "save",
- title: title,
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background...");
@@ -363,46 +249,9 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
-
- // Log save error
- try {
- await createLog({
- level: "ERROR",
- status: "FAILED",
- message: `Failed to save document ${documentId}: ${errorData.detail || "Unknown error"}`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: documentId,
- action: "save",
- http_status: response.status,
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
throw new Error(errorData.detail || "Failed to save document");
}
- // Log successful save
- try {
- await createLog({
- level: "INFO",
- status: "SUCCESS",
- message: `Document ${documentId} saved successfully`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: documentId,
- action: "save",
- title: document?.title,
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
@@ -421,26 +270,6 @@ export default function EditorPage() {
: isNewNote
? "Failed to create note. Please try again."
: "Failed to save document. Please try again.";
-
- // Log save error
- try {
- await createLog({
- level: "ERROR",
- status: "FAILED",
- message: `Error saving document: ${errorMessage}`,
- source: "editor",
- search_space_id: searchSpaceId,
- log_metadata: {
- document_id: isNewNote ? null : documentId,
- is_new_note: isNewNote,
- action: "save",
- error_type: "save_exception",
- },
- });
- } catch (err) {
- console.error("Failed to create log:", err);
- }
-
setError(errorMessage);
toast.error(errorMessage);
} finally {
@@ -557,7 +386,7 @@ export default function EditorPage() {
{/* Editor Container */}
-
+
{error && (
=> {
+ async (roleData: CreateRoleRequest["data"]): Promise => {
const request: CreateRoleRequest = {
search_space_id: searchSpaceId,
data: roleData,
@@ -1219,7 +1228,7 @@ function CreateRoleDialog({
onCreateRole,
}: {
groupedPermissions: Record;
- onCreateRole: (data: CreateRoleRequest['data']) => Promise;
+ onCreateRole: (data: CreateRoleRequest["data"]) => Promise;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx
index ed39593e3..dbf5b7155 100644
--- a/surfsense_web/app/dashboard/page.tsx
+++ b/surfsense_web/app/dashboard/page.tsx
@@ -8,6 +8,9 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
+import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
+import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown";
@@ -35,10 +38,7 @@ import {
} from "@/components/ui/card";
import { Spotlight } from "@/components/ui/spotlight";
import { Tilt } from "@/components/ui/tilt";
-import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
-import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
/**
* Formats a date string into a readable format
diff --git a/surfsense_web/app/dashboard/searchspaces/page.tsx b/surfsense_web/app/dashboard/searchspaces/page.tsx
index 76e17c0ce..ae3956a54 100644
--- a/surfsense_web/app/dashboard/searchspaces/page.tsx
+++ b/surfsense_web/app/dashboard/searchspaces/page.tsx
@@ -3,8 +3,8 @@
import { useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
-import { SearchSpaceForm } from "@/components/search-space-form";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
+import { SearchSpaceForm } from "@/components/search-space-form";
export default function SearchSpacesPage() {
const router = useRouter();
diff --git a/surfsense_web/atoms/roles/roles-mutation.atoms.ts b/surfsense_web/atoms/roles/roles-mutation.atoms.ts
index 47ece8b68..ddbc68ca2 100644
--- a/surfsense_web/atoms/roles/roles-mutation.atoms.ts
+++ b/surfsense_web/atoms/roles/roles-mutation.atoms.ts
@@ -3,10 +3,10 @@ import { toast } from "sonner";
import type {
CreateRoleRequest,
CreateRoleResponse,
- UpdateRoleRequest,
- UpdateRoleResponse,
DeleteRoleRequest,
DeleteRoleResponse,
+ UpdateRoleRequest,
+ UpdateRoleResponse,
} from "@/contracts/types/roles.types";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@@ -40,7 +40,10 @@ export const updateRoleMutationAtom = atomWithMutation(() => {
queryKey: cacheKeys.roles.all(request.search_space_id.toString()),
});
queryClient.invalidateQueries({
- queryKey: cacheKeys.roles.byId(request.search_space_id.toString(), request.role_id.toString()),
+ queryKey: cacheKeys.roles.byId(
+ request.search_space_id.toString(),
+ request.role_id.toString()
+ ),
});
},
onError: () => {
diff --git a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts
index ea1415869..62f23507b 100644
--- a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts
+++ b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts
@@ -2,13 +2,13 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateSearchSpaceRequest,
- UpdateSearchSpaceRequest,
DeleteSearchSpaceRequest,
+ UpdateSearchSpaceRequest,
} from "@/contracts/types/search-space.types";
-import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
+import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
export const createSearchSpaceMutationAtom = atomWithMutation(() => {
return {
diff --git a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts
index 1f03e25a2..4aa024e93 100644
--- a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts
+++ b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts
@@ -1,5 +1,5 @@
-import { atomWithQuery } from "jotai-tanstack-query";
import { atom } from "jotai";
+import { atomWithQuery } from "jotai-tanstack-query";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx
index c6b264879..abf77da5e 100644
--- a/surfsense_web/components/dashboard-breadcrumb.tsx
+++ b/surfsense_web/components/dashboard-breadcrumb.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -13,10 +14,9 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
-import { useQuery } from "@tanstack/react-query";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface {
label: string;
@@ -44,7 +44,7 @@ export function DashboardBreadcrumb() {
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
-
+
// Skip fetch for "new" notes
if (documentId === "new") {
setDocumentTitle(null);
@@ -124,7 +124,7 @@ export function DashboardBreadcrumb() {
} else {
documentLabel = documentTitle || subSection;
}
-
+
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
diff --git a/surfsense_web/components/onboard/setup-prompt-step.tsx b/surfsense_web/components/onboard/setup-prompt-step.tsx
index 5e3683031..b53e49700 100644
--- a/surfsense_web/components/onboard/setup-prompt-step.tsx
+++ b/surfsense_web/components/onboard/setup-prompt-step.tsx
@@ -1,8 +1,10 @@
"use client";
+import { useAtomValue } from "jotai";
import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -12,9 +14,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
-import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { useAtomValue } from "jotai";
interface SetupPromptStepProps {
searchSpaceId: number;
diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx
index dae842305..82456919c 100644
--- a/surfsense_web/components/settings/prompt-config-manager.tsx
+++ b/surfsense_web/components/settings/prompt-config-manager.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
import {
ChevronDown,
ChevronUp,
@@ -12,6 +14,7 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -23,19 +26,20 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
-import { useQuery } from "@tanstack/react-query";
-import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-import { useAtomValue } from "jotai";
import { authenticatedFetch } from "@/lib/auth-utils";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
interface PromptConfigManagerProps {
searchSpaceId: number;
}
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
- const { data: searchSpace, isLoading: loading, refetch: fetchSearchSpace } = useQuery({
+ const {
+ data: searchSpace,
+ isLoading: loading,
+ refetch: fetchSearchSpace,
+ } = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
index ea717e125..ca05b0e3f 100644
--- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx
+++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -8,6 +9,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button";
import {
@@ -18,11 +20,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { useQuery } from "@tanstack/react-query";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
interface AppSidebarProviderProps {
searchSpaceId: string;
diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx
index f2065edef..11c4f80ec 100644
--- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx
+++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx
@@ -1,60 +1,36 @@
"use client";
-import { FileText, type LucideIcon, MoreHorizontal, Plus, RefreshCw, Trash2 } from "lucide-react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { notesApiService } from "@/lib/apis/notes-api.service";
+import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import {
- SidebarGroup,
- SidebarGroupContent,
- SidebarMenu,
- SidebarMenuAction,
- SidebarMenuButton,
- SidebarMenuItem,
-} from "@/components/ui/sidebar";
-import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { documentsApiService } from "@/lib/apis/documents-api.service";
+import { notesApiService } from "@/lib/apis/notes-api.service";
import { cn } from "@/lib/utils";
-import { useEffect, useRef } from "react";
-import { createPortal } from "react-dom";
-
-// Map of icon names to their components
-const actionIconMap: Record = {
- FileText,
- Trash2,
- MoreHorizontal,
- RefreshCw,
-};
-
-interface NoteAction {
- name: string;
- icon: string;
- onClick: () => void;
-}
-
-interface NoteItem {
- name: string;
- url: string;
- icon: LucideIcon;
- id?: number;
- search_space_id?: number;
- actions?: NoteAction[];
-}
interface AllNotesSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onAddNote?: () => void;
- hoverTimeoutRef?: React.MutableRefObject;
}
export function AllNotesSidebar({
@@ -62,315 +38,242 @@ export function AllNotesSidebar({
onOpenChange,
searchSpaceId,
onAddNote,
- hoverTimeoutRef,
}: AllNotesSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
- const [isDeleting, setIsDeleting] = useState(null);
- const sidebarRef = useRef(null);
- const [sidebarLeft, setSidebarLeft] = useState(0); // Position from left edge of viewport
+ const queryClient = useQueryClient();
+ const [deletingNoteId, setDeletingNoteId] = useState(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
- // Calculate the sidebar's right edge position
- useEffect(() => {
- if (typeof window === "undefined") return;
-
- const updatePosition = () => {
- // Find the actual sidebar element (the fixed positioned one)
- const sidebarElement = document.querySelector(
- '[data-slot="sidebar"][data-sidebar="sidebar"]'
- ) as HTMLElement;
-
- if (sidebarElement) {
- const rect = sidebarElement.getBoundingClientRect();
- // Set the left position to be the right edge of the sidebar
- setSidebarLeft(rect.right);
- } else {
- // Fallback: try to find any sidebar element
- const fallbackSidebar = document.querySelector('[data-slot="sidebar"]') as HTMLElement;
- if (fallbackSidebar) {
- const rect = fallbackSidebar.getBoundingClientRect();
- setSidebarLeft(rect.right);
- } else {
- // Final fallback: use CSS variable
- const sidebarWidth = getComputedStyle(document.documentElement)
- .getPropertyValue("--sidebar-width")
- .trim();
- if (sidebarWidth) {
- const remValue = parseFloat(sidebarWidth);
- setSidebarLeft(remValue * 16); // Convert rem to px
- } else {
- setSidebarLeft(256); // Default 16rem
- }
- }
- }
- };
-
- updatePosition();
- // Update on window resize and scroll
- window.addEventListener("resize", updatePosition);
- window.addEventListener("scroll", updatePosition, true);
-
- // Use MutationObserver to watch for sidebar state changes
- const observer = new MutationObserver(updatePosition);
- const sidebarWrapper = document.querySelector('[data-slot="sidebar-wrapper"]');
- if (sidebarWrapper) {
- observer.observe(sidebarWrapper, {
- attributes: true,
- attributeFilter: ["data-state", "class"],
- childList: true,
- subtree: true,
- });
- }
-
- // Also observe the sidebar element directly if it exists
- const sidebarElement = document.querySelector('[data-slot="sidebar"]');
- if (sidebarElement) {
- observer.observe(sidebarElement, {
- attributes: true,
- attributeFilter: ["data-state", "class"],
- childList: false,
- subtree: false,
- });
- }
-
- return () => {
- window.removeEventListener("resize", updatePosition);
- window.removeEventListener("scroll", updatePosition, true);
- observer.disconnect();
- };
- }, []);
-
- // Handle Escape key to close sidebar
- useEffect(() => {
- if (!open) return;
-
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- onOpenChange(false);
- }
- };
-
- window.addEventListener("keydown", handleEscape);
- return () => window.removeEventListener("keydown", handleEscape);
- }, [open, onOpenChange]);
-
- // Fetch all notes
+ // Fetch all notes (when no search query)
const {
data: notesData,
error: notesError,
isLoading: isLoadingNotes,
- refetch: refetchNotes,
} = useQuery({
queryKey: ["all-notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
- page_size: 1000, // Get all notes
+ page_size: 1000,
}),
- enabled: !!searchSpaceId && open, // Only fetch when sidebar is open
+ enabled: !!searchSpaceId && open && !debouncedSearchQuery,
});
- // Handle note deletion with loading state
+ // Search notes (when there's a search query)
+ const {
+ data: searchData,
+ error: searchError,
+ isLoading: isSearching,
+ } = useQuery({
+ queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery],
+ queryFn: () =>
+ documentsApiService.searchDocuments({
+ queryParams: {
+ search_space_id: Number(searchSpaceId),
+ document_types: ["NOTE"],
+ title: debouncedSearchQuery,
+ page_size: 100,
+ },
+ }),
+ enabled: !!searchSpaceId && open && !!debouncedSearchQuery,
+ });
+
+ // Handle note navigation
+ const handleNoteClick = useCallback(
+ (noteId: number, noteSearchSpaceId: number) => {
+ router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`);
+ onOpenChange(false);
+ },
+ [router, onOpenChange]
+ );
+
+ // Handle note deletion
const handleDeleteNote = useCallback(
- async (noteId: number, deleteAction: () => void) => {
- setIsDeleting(noteId);
+ async (noteId: number, noteSearchSpaceId: number) => {
+ setDeletingNoteId(noteId);
try {
- await deleteAction();
- refetchNotes();
+ await notesApiService.deleteNote({
+ search_space_id: noteSearchSpaceId,
+ note_id: noteId,
+ });
+ // Invalidate queries to refresh the list
+ queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] });
+ queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] });
+ queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] });
+ } catch (error) {
+ console.error("Error deleting note:", error);
} finally {
- setIsDeleting(null);
+ setDeletingNoteId(null);
}
},
- [refetchNotes]
+ [queryClient, searchSpaceId]
);
- // Transform notes to the format expected by the component
- const allNotes = useMemo(() => {
- return notesData?.items
- ? notesData.items.map((note) => ({
- name: note.title,
- url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
- icon: FileText as LucideIcon,
- id: note.id,
- search_space_id: note.search_space_id,
- actions: [
- {
- name: "Delete",
- icon: "Trash2",
- onClick: async () => {
- try {
- await notesApiService.deleteNote({
- search_space_id: note.search_space_id,
- note_id: note.id,
- });
- } catch (error) {
- console.error("Error deleting note:", error);
- }
- },
- },
- ],
- }))
- : [];
- }, [notesData]);
+ // Clear search
+ const handleClearSearch = useCallback(() => {
+ setSearchQuery("");
+ }, []);
- // Enhanced note item component
- const NoteItemComponent = useCallback(
- ({ note }: { note: NoteItem }) => {
- const isDeletingNote = isDeleting === note.id;
+ // Determine which data to show
+ const isSearchMode = !!debouncedSearchQuery;
+ const isLoading = isSearchMode ? isSearching : isLoadingNotes;
+ const error = isSearchMode ? searchError : notesError;
- return (
-
- {
- router.push(note.url);
- onOpenChange(false); // Close sidebar when navigating
- }}
- disabled={isDeletingNote}
- className={cn("group/item relative", isDeletingNote && "opacity-50")}
- >
-
- {note.name}
-
+ // Transform notes data - handle both regular notes and search results
+ const notes = useMemo(() => {
+ if (isSearchMode && searchData?.items) {
+ return searchData.items.map((doc) => ({
+ id: doc.id,
+ title: doc.title,
+ search_space_id: doc.search_space_id,
+ }));
+ }
+ return notesData?.items ?? [];
+ }, [isSearchMode, searchData, notesData]);
- {note.actions && note.actions.length > 0 && (
-
-
-
-
- More
-
-
-
- {note.actions.map((action, actionIndex) => {
- const ActionIcon = actionIconMap[action.icon] || FileText;
- const isDeleteAction = action.name.toLowerCase().includes("delete");
+ return (
+
+
+
+ {t("all_notes") || "All Notes"}
+
+ {t("all_notes_description") || "Browse and manage all your notes"}
+
- return (
- {
- if (isDeleteAction) {
- handleDeleteNote(note.id || 0, action.onClick);
- } else {
- action.onClick();
- }
- }}
- disabled={isDeletingNote}
- className={isDeleteAction ? "text-destructive" : ""}
- >
-
- {isDeletingNote && isDeleteAction ? "Deleting..." : action.name}
-
- );
- })}
-
-
- )}
-
- );
- },
- [isDeleting, router, onOpenChange, handleDeleteNote]
- );
+ {/* Search Input */}
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 pr-8 h-9"
+ />
+ {searchQuery && (
+
+ )}
+
+
- const sidebarContent = (
- {
- // Clear any pending close timeout when hovering over sidebar
- if (hoverTimeoutRef?.current) {
- clearTimeout(hoverTimeoutRef.current);
- hoverTimeoutRef.current = null;
- }
- }}
- onMouseLeave={() => {
- // Close sidebar when mouse leaves
- if (hoverTimeoutRef) {
- hoverTimeoutRef.current = setTimeout(() => {
- onOpenChange(false);
- }, 200);
- } else {
- onOpenChange(false);
- }
- }}
- >
-
- {/* Header */}
-
-
{t("all_notes") || "All Notes"}
-
-
- {/* Content */}
-
-
- {isLoadingNotes ? (
-
-
-
- {t("loading") || "Loading..."}
-
-
-
- ) : notesError ? (
-
-
-
- {t("error_loading_notes") || "Error loading notes"}
-
-
-
- ) : allNotes.length > 0 ? (
-
- {allNotes.map((note) => (
-
- ))}
-
- ) : (
-
- {onAddNote ? (
- {
- onAddNote();
- onOpenChange(false);
- }}
- className="text-muted-foreground hover:text-sidebar-foreground text-xs"
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ {t("error_loading_notes") || "Error loading notes"}
+
+ ) : notes.length > 0 ? (
+
+ {notes.map((note) => {
+ const isDeleting = deletingNoteId === note.id;
+
+ return (
+
+ {/* Main clickable area for navigation */}
+
+
+ {/* Actions dropdown - separate from main click area */}
+
+
+
+
+
+ handleDeleteNote(note.id, note.search_space_id)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
+ );
+ })}
+
+ ) : isSearchMode ? (
+
+
+
+ {t("no_results_found") || "No notes found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
+
+ ) : (
+
+
+
+ {t("no_notes") || "No notes yet"}
+
+ {onAddNote && (
+
)}
-
-
+
+ )}
{/* Footer with Add Note button */}
- {onAddNote && (
-
+ {onAddNote && notes.length > 0 && (
+
)}
-
-
+
+
);
-
- // Render sidebar via portal to avoid stacking context issues
- if (typeof window === "undefined") {
- return null;
- }
-
- return createPortal(sidebarContent, document.body);
}
diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx
index f1d690c7f..48e7c35b8 100644
--- a/surfsense_web/components/sidebar/app-sidebar.tsx
+++ b/surfsense_web/components/sidebar/app-sidebar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useAtomValue } from "jotai";
import {
AlertCircle,
BookOpen,
@@ -27,7 +28,7 @@ import {
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { memo, useEffect, useMemo, useState } from "react";
-
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
DropdownMenu,
DropdownMenuContent,
@@ -37,8 +38,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { useAtomValue } from "jotai";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
/**
* Generates a consistent color based on a string (email)
@@ -454,7 +453,11 @@ export const AppSidebar = memo(function AppSidebar({
)}
-
+
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx
index 60e5d9c12..b14ecea77 100644
--- a/surfsense_web/components/sidebar/nav-notes.tsx
+++ b/surfsense_web/components/sidebar/nav-notes.tsx
@@ -2,19 +2,18 @@
import {
ChevronRight,
- ExternalLink,
- Eye,
FileText,
+ FolderOpen,
+ Loader2,
type LucideIcon,
MoreHorizontal,
Plus,
- RefreshCw,
- Share,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
-import { useCallback, useState, useRef } from "react";
+import { useCallback, useState } from "react";
+import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
@@ -27,23 +26,12 @@ import {
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
- SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
- useSidebar,
} from "@/components/ui/sidebar";
+import { cn } from "@/lib/utils";
import { AllNotesSidebar } from "./all-notes-sidebar";
-// Map of icon names to their components
-const actionIconMap: Record = {
- ExternalLink,
- FileText,
- Share,
- Trash2,
- MoreHorizontal,
- RefreshCw,
-};
-
interface NoteAction {
name: string;
icon: string;
@@ -66,14 +54,19 @@ interface NavNotesProps {
searchSpaceId?: string;
}
+// Map of icon names to their components
+const actionIconMap: Record = {
+ FileText,
+ Trash2,
+ MoreHorizontal,
+};
+
export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
const t = useTranslations("sidebar");
- const { isMobile } = useSidebar();
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
- const hoverTimeoutRef = useRef(null);
// Handle note deletion with loading state
const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
@@ -85,132 +78,148 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }
}
}, []);
- // Enhanced note item component
- const NoteItemComponent = useCallback(
- ({ note }: { note: NoteItem }) => {
- const isDeletingNote = isDeleting === note.id;
-
- return (
-
- router.push(note.url)}
- disabled={isDeletingNote}
- className={`group/item relative ${isDeletingNote ? "opacity-50" : ""}`}
- >
-
- {note.name}
-
-
- {note.actions && note.actions.length > 0 && (
-
-
-
-
- More
-
-
-
- {note.actions.map((action, actionIndex) => {
- const ActionIcon = actionIconMap[action.icon] || FileText;
- const isDeleteAction = action.name.toLowerCase().includes("delete");
-
- return (
- {
- if (isDeleteAction) {
- handleDeleteNote(note.id || 0, action.onClick);
- } else {
- action.onClick();
- }
- }}
- disabled={isDeletingNote}
- className={isDeleteAction ? "text-destructive" : ""}
- >
-
- {isDeletingNote && isDeleteAction ? "Deleting..." : action.name}
-
- );
- })}
-
-
- )}
-
- );
+ // Handle note navigation
+ const handleNoteClick = useCallback(
+ (url: string) => {
+ router.push(url);
},
- [isDeleting, router, isMobile, handleDeleteNote]
+ [router]
);
return (
-
+
-
+
-
+
{t("notes") || "Notes"}
-
+
+ {/* Action buttons - always visible on hover */}
+
{searchSpaceId && notes.length > 0 && (
-
)}
{onAddNote && (
-
{
e.stopPropagation();
onAddNote();
}}
aria-label="Add note"
- className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
>
-
-
+
+
)}
+
- {/* Note Items */}
{notes.length > 0 ? (
- notes.map((note) => )
+ notes.map((note) => {
+ const isDeletingNote = isDeleting === note.id;
+
+ return (
+
+ {/* Main navigation button */}
+ handleNoteClick(note.url)}
+ disabled={isDeletingNote}
+ className={cn(
+ "pr-8", // Make room for the action button
+ isDeletingNote && "opacity-50"
+ )}
+ >
+
+ {note.name}
+
+
+ {/* Actions dropdown - positioned absolutely */}
+ {note.actions && note.actions.length > 0 && (
+
+
+
+
+ {isDeletingNote ? (
+
+ ) : (
+
+ )}
+ More options
+
+
+
+ {note.actions.map((action, actionIndex) => {
+ const ActionIcon = actionIconMap[action.icon] || FileText;
+ const isDeleteAction = action.name.toLowerCase().includes("delete");
+
+ return (
+ {
+ if (isDeleteAction) {
+ handleDeleteNote(note.id || 0, action.onClick);
+ } else {
+ action.onClick();
+ }
+ }}
+ disabled={isDeletingNote}
+ className={
+ isDeleteAction
+ ? "text-destructive focus:text-destructive"
+ : ""
+ }
+ >
+
+
+ {isDeletingNote && isDeleteAction
+ ? "Deleting..."
+ : action.name}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+ })
) : (
- /* Empty state with create button */
{onAddNote ? (
+
+ {/* All Notes Sheet */}
{searchSpaceId && (
)}
diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx
index 74e0e4671..51e67d2f0 100644
--- a/surfsense_web/components/sidebar/page-usage-display.tsx
+++ b/surfsense_web/components/sidebar/page-usage-display.tsx
@@ -60,4 +60,4 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
);
-}
\ No newline at end of file
+}
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index 18521edb7..a12d26197 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -17,7 +17,17 @@ import {
IconTicket,
IconWorldWww,
} from "@tabler/icons-react";
-import { File, FileText, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
+import {
+ File,
+ FileText,
+ Globe,
+ Link,
+ Microscope,
+ Search,
+ Sparkles,
+ Telescope,
+ Webhook,
+} from "lucide-react";
import { EnumConnectorName } from "./connector";
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index abffc68a5..3ce5388dd 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -19,6 +19,7 @@ export const documentTypeEnum = z.enum([
"LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR",
"LINEAR_CONNECTOR",
+ "NOTE",
]);
export const document = z.object({
@@ -27,7 +28,10 @@ export const document = z.object({
document_type: documentTypeEnum,
document_metadata: z.record(z.string(), z.any()),
content: z.string(),
+ content_hash: z.string(),
+ unique_identifier_hash: z.string().nullable(),
created_at: z.string(),
+ updated_at: z.string().nullable(),
search_space_id: z.number(),
});
@@ -68,6 +72,9 @@ export const getDocumentsRequest = z.object({
export const getDocumentsResponse = z.object({
items: z.array(document),
total: z.number(),
+ page: z.number(),
+ page_size: z.number(),
+ has_more: z.boolean(),
});
/**
@@ -118,6 +125,9 @@ export const searchDocumentsRequest = z.object({
export const searchDocumentsResponse = z.object({
items: z.array(document),
total: z.number(),
+ page: z.number(),
+ page_size: z.number(),
+ has_more: z.boolean(),
});
/**
diff --git a/surfsense_web/contracts/types/roles.types.ts b/surfsense_web/contracts/types/roles.types.ts
index 31ad0e970..9008a859a 100644
--- a/surfsense_web/contracts/types/roles.types.ts
+++ b/surfsense_web/contracts/types/roles.types.ts
@@ -51,12 +51,14 @@ export const getRoleByIdResponse = role;
export const updateRoleRequest = z.object({
search_space_id: z.number(),
role_id: z.number(),
- data: role.pick({
- name: true,
- description: true,
- permissions: true,
- is_default: true,
- }).partial(),
+ data: role
+ .pick({
+ name: true,
+ description: true,
+ permissions: true,
+ is_default: true,
+ })
+ .partial(),
});
export const updateRoleResponse = role;
diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts
index c0096b41c..b591fafbb 100644
--- a/surfsense_web/contracts/types/search-space.types.ts
+++ b/surfsense_web/contracts/types/search-space.types.ts
@@ -2,26 +2,26 @@ import { z } from "zod";
import { paginationQueryParams } from ".";
export const searchSpace = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string().nullable(),
- created_at: z.string(),
- user_id: z.string(),
- citations_enabled: z.boolean(),
- qna_custom_instructions: z.string().nullable(),
- member_count: z.number(),
- is_owner: z.boolean(),
+ id: z.number(),
+ name: z.string(),
+ description: z.string().nullable(),
+ created_at: z.string(),
+ user_id: z.string(),
+ citations_enabled: z.boolean(),
+ qna_custom_instructions: z.string().nullable(),
+ member_count: z.number(),
+ is_owner: z.boolean(),
});
/**
* Get search spaces
*/
export const getSearchSpacesRequest = z.object({
- queryParams: paginationQueryParams
- .extend({
- owned_only: z.boolean().optional(),
- })
- .nullish(),
+ queryParams: paginationQueryParams
+ .extend({
+ owned_only: z.boolean().optional(),
+ })
+ .nullish(),
});
export const getSearchSpacesResponse = z.array(searchSpace);
@@ -29,12 +29,10 @@ export const getSearchSpacesResponse = z.array(searchSpace);
/**
* Create search space
*/
-export const createSearchSpaceRequest = searchSpace
- .pick({ name: true, description: true })
- .extend({
- citations_enabled: z.boolean().default(true).optional(),
- qna_custom_instructions: z.string().nullable().optional(),
- });
+export const createSearchSpaceRequest = searchSpace.pick({ name: true, description: true }).extend({
+ citations_enabled: z.boolean().default(true).optional(),
+ qna_custom_instructions: z.string().nullable().optional(),
+});
export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
@@ -42,13 +40,13 @@ export const createSearchSpaceResponse = searchSpace.omit({ member_count: true,
* Get community prompts
*/
export const getCommunityPromptsResponse = z.array(
- z.object({
- key: z.string(),
- value: z.string(),
- author: z.string(),
- link: z.string(),
- category: z.string(),
- })
+ z.object({
+ key: z.string(),
+ value: z.string(),
+ author: z.string(),
+ link: z.string(),
+ category: z.string(),
+ })
);
/**
@@ -62,10 +60,10 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_
* Update search space
*/
export const updateSearchSpaceRequest = z.object({
- id: z.number(),
- data: searchSpace
- .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
- .partial(),
+ id: z.number(),
+ data: searchSpace
+ .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
+ .partial(),
});
export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
@@ -76,7 +74,7 @@ export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true,
export const deleteSearchSpaceRequest = searchSpace.pick({ id: true });
export const deleteSearchSpaceResponse = z.object({
- message: z.literal("Search space deleted successfully"),
+ message: z.literal("Search space deleted successfully"),
});
// Inferred types
diff --git a/surfsense_web/hooks/index.ts b/surfsense_web/hooks/index.ts
index d2a4ff6bf..db454c161 100644
--- a/surfsense_web/hooks/index.ts
+++ b/surfsense_web/hooks/index.ts
@@ -1,3 +1,4 @@
+export * from "./use-debounced-value";
export * from "./use-logs";
export * from "./use-rbac";
export * from "./use-search-source-connectors";
diff --git a/surfsense_web/hooks/use-debounced-value.ts b/surfsense_web/hooks/use-debounced-value.ts
new file mode 100644
index 000000000..6b4ba2adb
--- /dev/null
+++ b/surfsense_web/hooks/use-debounced-value.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Hook that returns a debounced value that only updates after the specified delay
+ * @param value - The value to debounce
+ * @param delay - The delay in milliseconds (default: 300ms)
+ * @returns The debounced value
+ */
+export function useDebouncedValue
(value: T, delay: number = 300): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts
index 6ce025e89..cfd161de0 100644
--- a/surfsense_web/hooks/use-logs.ts
+++ b/surfsense_web/hooks/use-logs.ts
@@ -141,32 +141,43 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
);
// Function to create a new log
- const createLog = useCallback(async (logData: Omit) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
- {
- headers: { "Content-Type": "application/json" },
- method: "POST",
- body: JSON.stringify(logData),
+ // Use silent: true to suppress toast notifications (for internal/background operations)
+ const createLog = useCallback(
+ async (logData: Omit, options?: { silent?: boolean }) => {
+ const { silent = false } = options || {};
+ try {
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
+ {
+ headers: { "Content-Type": "application/json" },
+ method: "POST",
+ body: JSON.stringify(logData),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.detail || "Failed to create log");
}
- );
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to create log");
+ const newLog = await response.json();
+ setLogs((prevLogs) => [newLog, ...prevLogs]);
+ // Only show toast if not silent
+ if (!silent) {
+ toast.success("Log created successfully");
+ }
+ return newLog;
+ } catch (err: any) {
+ // Only show error toast if not silent
+ if (!silent) {
+ toast.error(err.message || "Failed to create log");
+ }
+ console.error("Error creating log:", err);
+ throw err;
}
-
- const newLog = await response.json();
- setLogs((prevLogs) => [newLog, ...prevLogs]);
- toast.success("Log created successfully");
- return newLog;
- } catch (err: any) {
- toast.error(err.message || "Failed to create log");
- console.error("Error creating log:", err);
- throw err;
- }
- }, []);
+ },
+ []
+ );
// Function to update a log
const updateLog = useCallback(
diff --git a/surfsense_web/lib/apis/notes-api.service.ts b/surfsense_web/lib/apis/notes-api.service.ts
index 38a48e0d7..5e8ab8a96 100644
--- a/surfsense_web/lib/apis/notes-api.service.ts
+++ b/surfsense_web/lib/apis/notes-api.service.ts
@@ -145,4 +145,3 @@ class NotesApiService {
}
export const notesApiService = new NotesApiService();
-
diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts
index 92083293a..1a574ee4b 100644
--- a/surfsense_web/lib/apis/roles-api.service.ts
+++ b/surfsense_web/lib/apis/roles-api.service.ts
@@ -6,9 +6,9 @@ import {
deleteRoleRequest,
deleteRoleResponse,
type GetRoleByIdRequest,
+ type GetRolesRequest,
getRoleByIdRequest,
getRoleByIdResponse,
- type GetRolesRequest,
getRolesRequest,
getRolesResponse,
type UpdateRoleRequest,
@@ -34,7 +34,7 @@ class RolesApiService {
createRoleResponse,
{
body: parsedRequest.data.data,
- },
+ }
);
};
@@ -50,7 +50,7 @@ class RolesApiService {
return baseApiService.get(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`,
- getRolesResponse,
+ getRolesResponse
);
};
@@ -66,7 +66,7 @@ class RolesApiService {
return baseApiService.get(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`,
- getRoleByIdResponse,
+ getRoleByIdResponse
);
};
@@ -85,7 +85,7 @@ class RolesApiService {
updateRoleResponse,
{
body: parsedRequest.data.data,
- },
+ }
);
};
@@ -101,7 +101,7 @@ class RolesApiService {
return baseApiService.delete(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`,
- deleteRoleResponse,
+ deleteRoleResponse
);
};
}
diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts
index 73b57ee3c..23433faee 100644
--- a/surfsense_web/lib/apis/search-spaces-api.service.ts
+++ b/surfsense_web/lib/apis/search-spaces-api.service.ts
@@ -1,18 +1,18 @@
import {
type CreateSearchSpaceRequest,
- type DeleteSearchSpaceRequest,
- type GetSearchSpaceRequest,
- type GetSearchSpacesRequest,
- type UpdateSearchSpaceRequest,
createSearchSpaceRequest,
createSearchSpaceResponse,
+ type DeleteSearchSpaceRequest,
deleteSearchSpaceRequest,
deleteSearchSpaceResponse,
+ type GetSearchSpaceRequest,
+ type GetSearchSpacesRequest,
getCommunityPromptsResponse,
getSearchSpaceRequest,
getSearchSpaceResponse,
getSearchSpacesRequest,
getSearchSpacesResponse,
+ type UpdateSearchSpaceRequest,
updateSearchSpaceRequest,
updateSearchSpaceResponse,
} from "@/contracts/types/search-space.types";
@@ -71,7 +71,10 @@ class SearchSpacesApiService {
* Get community-curated prompts for search space system instructions
*/
getCommunityPrompts = async () => {
- return baseApiService.get(`/api/v1/searchspaces/prompts/community`, getCommunityPromptsResponse);
+ return baseApiService.get(
+ `/api/v1/searchspaces/prompts/community`,
+ getCommunityPromptsResponse
+ );
};
/**
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 6ac7c6a6e..db7af6636 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -2,8 +2,8 @@ import type { GetChatsRequest } from "@/contracts/types/chat.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
-import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
import type { GetRolesRequest } from "@/contracts/types/roles.types";
+import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
export const cacheKeys = {
chats: {
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index 376e76a07..e2cf89b5f 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -645,6 +645,10 @@
"search_space": "Search Space",
"notes": "Notes",
"all_notes": "All Notes",
+ "all_notes_description": "Browse and manage all your notes",
+ "search_notes": "Search notes...",
+ "no_results_found": "No notes found",
+ "try_different_search": "Try a different search term",
"no_notes": "No notes yet",
"create_new_note": "Create a new note",
"error_loading_notes": "Error loading notes",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index e9a50930c..38546bb87 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -645,6 +645,10 @@
"search_space": "搜索空间",
"notes": "笔记",
"all_notes": "所有笔记",
+ "all_notes_description": "浏览和管理您的所有笔记",
+ "search_notes": "搜索笔记...",
+ "no_results_found": "未找到笔记",
+ "try_different_search": "尝试其他搜索词",
"no_notes": "暂无笔记",
"create_new_note": "创建新笔记",
"error_loading_notes": "加载笔记时出错",