From 72205ce11b4b3e00624a20c94eebe6ddd7beec4a Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 18:09:05 +0530
Subject: [PATCH 06/19] feat: implement Redis heartbeat mechanism for document
processing tasks and enhance stale notification cleanup
---
.../app/tasks/celery_tasks/document_tasks.py | 137 +++++++++--
.../stale_notification_cleanup_task.py | 225 ++++++++++++++++--
.../document_processors/youtube_processor.py | 18 +-
3 files changed, 350 insertions(+), 30 deletions(-)
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
index 6dfcbff46..7fd866f1c 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
@@ -1,6 +1,8 @@
"""Celery tasks for document processing."""
+import asyncio
import logging
+import os
from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@@ -17,6 +19,79 @@ from app.tasks.document_processors import (
logger = logging.getLogger(__name__)
+# ===== Redis heartbeat for document processing tasks =====
+# Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py).
+# A background coroutine refreshes a Redis key every 60s with a 2-min TTL.
+# If the Celery worker crashes, the coroutine dies, the key expires, and the
+# stale_notification_cleanup_task detects the missing key and marks the
+# notification + document as failed.
+_doc_heartbeat_redis = None
+HEARTBEAT_TTL_SECONDS = 120 # 2 minutes — same as connector indexing
+HEARTBEAT_REFRESH_INTERVAL = 60 # Refresh every 60 seconds
+
+
+def _get_doc_heartbeat_redis():
+ """Get Redis client for document processing heartbeat."""
+ import redis
+
+ global _doc_heartbeat_redis
+ if _doc_heartbeat_redis is None:
+ redis_url = os.getenv(
+ "REDIS_APP_URL",
+ os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
+ )
+ _doc_heartbeat_redis = redis.from_url(redis_url, decode_responses=True)
+ return _doc_heartbeat_redis
+
+
+def _get_heartbeat_key(notification_id: int) -> str:
+ """Generate Redis key for document processing heartbeat.
+
+ Uses same key pattern as connector indexing: indexing:heartbeat:{notification_id}
+ """
+ return f"indexing:heartbeat:{notification_id}"
+
+
+def _start_heartbeat(notification_id: int) -> None:
+ """Set initial Redis heartbeat key for a document processing task."""
+ try:
+ key = _get_heartbeat_key(notification_id)
+ _get_doc_heartbeat_redis().setex(key, HEARTBEAT_TTL_SECONDS, "started")
+ except Exception as e:
+ logger.warning(
+ f"Failed to set initial heartbeat for notification {notification_id}: {e}"
+ )
+
+
+def _stop_heartbeat(notification_id: int) -> None:
+ """Delete Redis heartbeat key when task completes (success or failure)."""
+ try:
+ key = _get_heartbeat_key(notification_id)
+ _get_doc_heartbeat_redis().delete(key)
+ except Exception:
+ pass # Key will expire on its own
+
+
+async def _run_heartbeat_loop(notification_id: int):
+ """Background coroutine that refreshes Redis heartbeat every 60 seconds.
+
+ This keeps the heartbeat alive while the task is running.
+ When the task finishes, this coroutine is cancelled via heartbeat_task.cancel().
+ When the worker crashes, this coroutine dies with it and the key expires.
+ """
+ key = _get_heartbeat_key(notification_id)
+ try:
+ while True:
+ await asyncio.sleep(HEARTBEAT_REFRESH_INTERVAL)
+ try:
+ _get_doc_heartbeat_redis().setex(key, HEARTBEAT_TTL_SECONDS, "alive")
+ except Exception as e:
+ logger.warning(
+ f"Failed to refresh heartbeat for notification {notification_id}: {e}"
+ )
+ except asyncio.CancelledError:
+ pass # Normal cancellation when task completes
+
def get_celery_session_maker():
"""
@@ -44,8 +119,6 @@ def process_extension_document_task(
search_space_id: ID of the search space
user_id: ID of the user
"""
- import asyncio
-
# Create a new event loop for this task
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@@ -196,8 +269,6 @@ def process_youtube_video_task(self, url: str, search_space_id: int, user_id: st
search_space_id: ID of the search space
user_id: ID of the user
"""
- import asyncio
-
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@@ -226,6 +297,10 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
)
)
+ # Start Redis heartbeat for stale task detection
+ _start_heartbeat(notification.id)
+ heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
+
log_entry = await task_logger.log_task_start(
task_name="process_youtube_video",
source="document_processor",
@@ -243,7 +318,7 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
)
result = await add_youtube_video_document(
- session, url, search_space_id, user_id
+ session, url, search_space_id, user_id, notification=notification
)
if result:
@@ -307,6 +382,10 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
logger.error(f"Error processing YouTube video: {e!s}")
raise
+ finally:
+ # Stop heartbeat — key deleted on success, expires on crash
+ heartbeat_task.cancel()
+ _stop_heartbeat(notification.id)
@celery_app.task(name="process_file_upload", bind=True)
@@ -322,8 +401,6 @@ def process_file_upload_task(
search_space_id: ID of the search space
user_id: ID of the user
"""
- import asyncio
- import os
import traceback
logger.info(
@@ -370,8 +447,6 @@ async def _process_file_upload(
file_path: str, filename: str, search_space_id: int, user_id: str
):
"""Process file upload with new session."""
- import os
-
from app.tasks.document_processors.file_processors import process_file_in_background
logger.info(f"[_process_file_upload] Starting async processing for: {filename}")
@@ -404,6 +479,10 @@ async def _process_file_upload(
f"[_process_file_upload] Notification created with ID: {notification.id if notification else 'None'}"
)
+ # Start Redis heartbeat for stale task detection
+ _start_heartbeat(notification.id)
+ heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
+
log_entry = await task_logger.log_task_start(
task_name="process_file_upload",
source="document_processor",
@@ -535,6 +614,10 @@ async def _process_file_upload(
)
logger.error(error_message)
raise
+ finally:
+ # Stop heartbeat — key deleted on success, expires on crash
+ heartbeat_task.cancel()
+ _stop_heartbeat(notification.id)
@celery_app.task(name="process_file_upload_with_document", bind=True)
@@ -560,8 +643,6 @@ def process_file_upload_with_document_task(
search_space_id: ID of the search space
user_id: ID of the user
"""
- import asyncio
- import os
import traceback
logger.info(
@@ -640,8 +721,6 @@ async def _process_file_with_document(
- Processes the file (parsing, embedding, chunking)
- Updates document to 'ready' on success or 'failed' on error
"""
- import os
-
from app.db import Document, DocumentStatus
from app.tasks.document_processors.base import get_current_timestamp
from app.tasks.document_processors.file_processors import (
@@ -689,6 +768,19 @@ async def _process_file_with_document(
)
)
+ # Store document_id in notification metadata so cleanup task can find the document
+ if notification and notification.notification_metadata is not None:
+ notification.notification_metadata["document_id"] = document_id
+ from sqlalchemy.orm.attributes import flag_modified
+
+ flag_modified(notification, "notification_metadata")
+ await session.commit()
+ await session.refresh(notification)
+
+ # Start Redis heartbeat for stale task detection
+ _start_heartbeat(notification.id)
+ heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
+
log_entry = await task_logger.log_task_start(
task_name="process_file_upload_with_document",
source="document_processor",
@@ -822,6 +914,10 @@ async def _process_file_with_document(
raise
finally:
+ # Stop heartbeat — key deleted on success, expires on crash
+ heartbeat_task.cancel()
+ _stop_heartbeat(notification.id)
+
# Clean up temp file
if os.path.exists(temp_path):
try:
@@ -856,8 +952,6 @@ def process_circleback_meeting_task(
search_space_id: ID of the search space
connector_id: ID of the Circleback connector (for deletion support)
"""
- import asyncio
-
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@@ -897,6 +991,7 @@ async def _process_circleback_meeting(
# Create notification if user_id is available
notification = None
+ heartbeat_task = None
if user_id:
notification = (
await NotificationService.document_processing.notify_processing_started(
@@ -908,6 +1003,12 @@ async def _process_circleback_meeting(
)
)
+ # Start Redis heartbeat for stale task detection
+ _start_heartbeat(notification.id)
+ heartbeat_task = asyncio.create_task(
+ _run_heartbeat_loop(notification.id)
+ )
+
log_entry = await task_logger.log_task_start(
task_name="process_circleback_meeting",
source="circleback_webhook",
@@ -1000,3 +1101,9 @@ async def _process_circleback_meeting(
logger.error(f"Error processing Circleback meeting: {e!s}")
raise
+ finally:
+ # Stop heartbeat — key deleted on success, expires on crash
+ if heartbeat_task:
+ heartbeat_task.cancel()
+ if notification:
+ _stop_heartbeat(notification.id)
diff --git a/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py b/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
index ef3a30e43..aebe40b88 100644
--- a/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
+++ b/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
@@ -1,18 +1,25 @@
-"""Celery task to detect and mark stale connector indexing notifications as failed.
+"""Celery task to detect and mark stale notifications as failed.
This task runs periodically (every 5 minutes by default) to find notifications
that are stuck in "in_progress" status but don't have an active Redis heartbeat key.
-These are marked as "failed" to prevent the frontend from showing a perpetual "syncing" state.
+These are marked as "failed" to prevent the frontend from showing a perpetual
+"syncing" or "processing" state.
-Additionally, it cleans up documents stuck in pending/processing state that belong
-to connectors with stale notifications.
+It handles two notification types:
+1. **connector_indexing** — connector sync tasks (Google Calendar, etc.)
+2. **document_processing** — manual file uploads, YouTube videos, etc.
+
+Additionally, it cleans up documents stuck in pending/processing state:
+- For connectors: by connector_id
+- For non-connector documents (FILE uploads, YouTube): by document_id from notification metadata
Detection mechanism:
-- Active indexing tasks set a Redis key with TTL (2 minutes) as a heartbeat
-- If the task crashes, the Redis key expires automatically
+- Active tasks set a Redis key with TTL (2 minutes) as a heartbeat
+- A background coroutine refreshes the key every 60 seconds
+- If the task/worker crashes, the Redis key expires automatically
- This cleanup task checks for in-progress notifications without a Redis heartbeat key
- Such notifications are marked as failed with O(1) batch UPDATE
-- Documents with pending/processing status for those connectors are also marked as failed
+- Associated documents are also marked as failed
"""
import contextlib
@@ -36,8 +43,11 @@ logger = logging.getLogger(__name__)
# Redis client for checking heartbeats
_redis_client: redis.Redis | None = None
-# Error message shown to users when sync is interrupted
+# Error messages shown to users when tasks are interrupted
STALE_SYNC_ERROR_MESSAGE = "Sync was interrupted unexpectedly. Please retry."
+STALE_PROCESSING_ERROR_MESSAGE = (
+ "Processing was interrupted unexpectedly. Please retry."
+)
def get_redis_client() -> redis.Redis:
@@ -70,14 +80,13 @@ def get_celery_session_maker():
@celery_app.task(name="cleanup_stale_indexing_notifications")
def cleanup_stale_indexing_notifications_task():
"""
- Check for stale connector indexing notifications and mark them as failed.
+ Check for stale notifications and mark them as failed.
- This task finds notifications that:
- - Have type = 'connector_indexing'
- - Have metadata.status = 'in_progress'
- - Do NOT have a corresponding Redis heartbeat key (meaning task crashed)
+ Handles two notification types:
+ 1. connector_indexing — connector sync tasks
+ 2. document_processing — manual file uploads, YouTube videos, etc.
- And marks them as failed with O(1) batch UPDATE.
+ Detection: Redis heartbeat key with 2-min TTL. Missing key = stale task.
Also marks associated pending/processing documents as failed.
"""
import asyncio
@@ -87,6 +96,7 @@ def cleanup_stale_indexing_notifications_task():
try:
loop.run_until_complete(_cleanup_stale_notifications())
+ loop.run_until_complete(_cleanup_stale_document_processing_notifications())
finally:
loop.close()
@@ -269,3 +279,190 @@ async def _cleanup_stuck_documents(session, connector_ids: list[int]):
exc_info=True,
)
# Don't raise - let the notification cleanup continue even if document cleanup fails
+
+
+# ===== Document Processing Cleanup (FILE uploads, YouTube, etc.) =====
+
+
+async def _cleanup_stale_document_processing_notifications():
+ """Find and mark stale document processing notifications as failed.
+
+ Same Redis heartbeat mechanism as connector indexing cleanup, but for
+ document_processing type notifications (FILE uploads, YouTube videos, etc.).
+
+ For each stale notification that contains a document_id in its metadata,
+ the associated document is also marked as failed.
+ """
+ async with get_celery_session_maker()() as session:
+ try:
+ # Find all in-progress document processing notifications
+ result = await session.execute(
+ select(
+ Notification.id,
+ Notification.notification_metadata,
+ ).where(
+ and_(
+ Notification.type == "document_processing",
+ Notification.notification_metadata["status"].astext
+ == "in_progress",
+ )
+ )
+ )
+ in_progress_rows = result.fetchall()
+
+ if not in_progress_rows:
+ logger.debug(
+ "No in-progress document processing notifications found"
+ )
+ return
+
+ # Check which ones are missing heartbeat keys in Redis
+ redis_client = get_redis_client()
+ stale_notification_ids = []
+ stale_document_ids = []
+
+ for row in in_progress_rows:
+ notification_id = row[0]
+ metadata = row[1] # Full metadata dict
+ heartbeat_key = _get_heartbeat_key(notification_id)
+ if not redis_client.exists(heartbeat_key):
+ stale_notification_ids.append(notification_id)
+ # Extract document_id from metadata for document cleanup
+ if metadata and isinstance(metadata, dict):
+ doc_id = metadata.get("document_id")
+ if doc_id is not None:
+ with contextlib.suppress(ValueError, TypeError):
+ stale_document_ids.append(int(doc_id))
+
+ if not stale_notification_ids:
+ logger.debug(
+ f"All {len(in_progress_rows)} in-progress document processing "
+ "notifications have active Redis heartbeats"
+ )
+ return
+
+ logger.warning(
+ f"Found {len(stale_notification_ids)} stale document processing "
+ f"notifications (no Redis heartbeat): {stale_notification_ids}"
+ )
+
+ # O(1) Batch UPDATE: Mark stale notifications as failed
+ update_data = {
+ "status": "failed",
+ "completed_at": datetime.now(UTC).isoformat(),
+ "error_message": STALE_PROCESSING_ERROR_MESSAGE,
+ "processing_stage": "failed",
+ }
+
+ await session.execute(
+ text("""
+ UPDATE notifications
+ SET metadata = metadata || CAST(:update_json AS jsonb),
+ title = 'Failed: ' || COALESCE(metadata->>'document_name', 'Document'),
+ message = :display_message
+ WHERE id = ANY(:ids)
+ """),
+ {
+ "update_json": json.dumps(update_data),
+ "display_message": STALE_PROCESSING_ERROR_MESSAGE,
+ "ids": stale_notification_ids,
+ },
+ )
+
+ logger.info(
+ f"Successfully marked {len(stale_notification_ids)} stale document "
+ "processing notifications as failed"
+ )
+
+ # Clean up stuck documents by document_id from notification metadata
+ if stale_document_ids:
+ await _cleanup_stuck_non_connector_documents(
+ session, stale_document_ids
+ )
+
+ await session.commit()
+
+ except Exception as e:
+ logger.error(
+ f"Error cleaning up stale document processing notifications: {e!s}",
+ exc_info=True,
+ )
+ await session.rollback()
+
+
+async def _cleanup_stuck_non_connector_documents(
+ session, document_ids: list[int]
+):
+ """
+ Mark specific non-connector documents stuck in pending/processing as failed.
+
+ These are documents (FILE uploads, YouTube, etc.) identified from stale
+ notification metadata. Only documents that are still in pending/processing
+ state are updated — already-completed documents are left untouched.
+
+ Args:
+ session: Database session
+ document_ids: List of document IDs to check and potentially mark as failed
+ """
+ if not document_ids:
+ return
+
+ try:
+ # Find which of these documents are actually stuck
+ count_result = await session.execute(
+ select(Document.id).where(
+ and_(
+ Document.id.in_(document_ids),
+ or_(
+ Document.status["state"].astext == DocumentStatus.PENDING,
+ Document.status["state"].astext == DocumentStatus.PROCESSING,
+ ),
+ )
+ )
+ )
+ stuck_doc_ids = [row[0] for row in count_result.fetchall()]
+
+ if not stuck_doc_ids:
+ logger.debug(
+ f"No stuck non-connector documents found for IDs: {document_ids}"
+ )
+ return
+
+ logger.warning(
+ f"Found {len(stuck_doc_ids)} stuck non-connector documents "
+ f"(pending/processing): {stuck_doc_ids}"
+ )
+
+ failed_status = DocumentStatus.failed(STALE_PROCESSING_ERROR_MESSAGE)
+
+ await session.execute(
+ text("""
+ UPDATE documents
+ SET status = CAST(:failed_status AS jsonb),
+ updated_at = :now
+ WHERE id = ANY(:doc_ids)
+ AND (
+ status->>'state' = :pending_state
+ OR status->>'state' = :processing_state
+ )
+ """),
+ {
+ "failed_status": json.dumps(failed_status),
+ "now": datetime.now(UTC),
+ "doc_ids": stuck_doc_ids,
+ "pending_state": DocumentStatus.PENDING,
+ "processing_state": DocumentStatus.PROCESSING,
+ },
+ )
+
+ logger.info(
+ f"Successfully marked {len(stuck_doc_ids)} stuck non-connector "
+ "documents as failed"
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Error cleaning up stuck non-connector documents {document_ids}: {e!s}",
+ exc_info=True,
+ )
+ # Don't raise — let the rest of the cleanup continue
diff --git a/surfsense_backend/app/tasks/document_processors/youtube_processor.py b/surfsense_backend/app/tasks/document_processors/youtube_processor.py
index 9dac6d554..427236fd3 100644
--- a/surfsense_backend/app/tasks/document_processors/youtube_processor.py
+++ b/surfsense_backend/app/tasks/document_processors/youtube_processor.py
@@ -61,7 +61,11 @@ def get_youtube_video_id(url: str) -> str | None:
async def add_youtube_video_document(
- session: AsyncSession, url: str, search_space_id: int, user_id: str
+ session: AsyncSession,
+ url: str,
+ search_space_id: int,
+ user_id: str,
+ notification=None,
) -> Document:
"""
Process a YouTube video URL, extract transcripts, and store as a document.
@@ -75,6 +79,9 @@ async def add_youtube_video_document(
url: YouTube video URL (supports standard, shortened, and embed formats)
search_space_id: ID of the search space to add the document to
user_id: ID of the user
+ notification: Optional notification object — if provided, the document_id
+ is stored in its metadata right after document creation so the stale
+ cleanup task can identify stuck documents.
Returns:
Document: The created document object
@@ -182,6 +189,15 @@ async def add_youtube_video_document(
await session.commit() # Document visible in UI now with pending status!
is_new_document = True
+ # Store document_id in notification metadata so stale cleanup task
+ # can identify this document if the worker crashes.
+ if notification and notification.notification_metadata is not None:
+ from sqlalchemy.orm.attributes import flag_modified
+
+ notification.notification_metadata["document_id"] = document.id
+ flag_modified(notification, "notification_metadata")
+ await session.commit()
+
logging.info(f"Created pending document for YouTube video {video_id}")
# =======================================================================
From e3faf4cc5e28b4ac48050dd82158ee33294c3e3c Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 18:12:46 +0530
Subject: [PATCH 07/19] feat: enhance document upload handling by managing
duplicates and updating statuses for existing documents
---
.../app/routes/documents_routes.py | 23 ++++++++++++++++---
.../app/tasks/celery_tasks/document_tasks.py | 6 ++---
2 files changed, 23 insertions(+), 6 deletions(-)
diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index b20f8cd9c..226d511cc 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -178,9 +178,26 @@ async def create_documents_file_upload(
session, unique_identifier_hash
)
if existing:
- # Clean up temp file for duplicates
- os.unlink(temp_path)
- skipped_duplicates += 1
+ if DocumentStatus.is_state(existing.status, DocumentStatus.READY):
+ # True duplicate — content already indexed, skip
+ os.unlink(temp_path)
+ skipped_duplicates += 1
+ continue
+
+ # Existing document is stuck (failed/pending/processing)
+ # Reset it to pending and re-dispatch for processing
+ existing.status = DocumentStatus.pending()
+ existing.content = "Processing..."
+ existing.document_metadata = {
+ **(existing.document_metadata or {}),
+ "file_size": file_size,
+ "upload_time": datetime.now().isoformat(),
+ }
+ existing.updated_at = get_current_timestamp()
+ created_documents.append(existing)
+ files_to_process.append(
+ (existing, temp_path, file.filename or "unknown")
+ )
continue
# Create pending document (visible immediately in UI via ElectricSQL)
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
index 7fd866f1c..dfbfea432 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
@@ -413,7 +413,7 @@ def process_file_upload_task(
if not os.path.exists(file_path):
logger.error(
f"[process_file_upload] File does not exist: {file_path}. "
- "The temp file may have been cleaned up before the task ran."
+ "File may have been removed before syncing could start."
)
return
@@ -654,7 +654,7 @@ def process_file_upload_with_document_task(
if not os.path.exists(temp_path):
logger.error(
f"[process_file_upload_with_document] File does not exist: {temp_path}. "
- "The temp file may have been cleaned up before the task ran."
+ "File may have been removed before syncing could start."
)
# Mark document as failed since file is missing
loop = asyncio.new_event_loop()
@@ -663,7 +663,7 @@ def process_file_upload_with_document_task(
loop.run_until_complete(
_mark_document_failed(
document_id,
- "File not found - temp file may have been cleaned up",
+ "File not found. Please re-upload the file.",
)
)
finally:
From 2470fb70a6bce30c62ad50749d7d0917b6bcec90 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 18:13:11 +0530
Subject: [PATCH 08/19] fix: update error messages for task interruptions and
refine document upload file size limits in English and Chinese translations
---
.../app/tasks/celery_tasks/stale_notification_cleanup_task.py | 3 +--
.../documents/(manage)/components/DocumentTypeIcon.tsx | 4 ++--
surfsense_web/messages/en.json | 2 +-
surfsense_web/messages/zh.json | 2 +-
4 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py b/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
index aebe40b88..c47652d2c 100644
--- a/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
+++ b/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
@@ -46,10 +46,9 @@ _redis_client: redis.Redis | None = None
# Error messages shown to users when tasks are interrupted
STALE_SYNC_ERROR_MESSAGE = "Sync was interrupted unexpectedly. Please retry."
STALE_PROCESSING_ERROR_MESSAGE = (
- "Processing was interrupted unexpectedly. Please retry."
+ "Syncing was interrupted unexpectedly. Please retry."
)
-
def get_redis_client() -> redis.Redis:
"""Get or create Redis client for heartbeat checking."""
global _redis_client
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx
index b214c96be..92ddb0057 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx
@@ -35,9 +35,9 @@ export function DocumentTypeChip({ type, className }: { type: string; className?
const chip = (
- {icon}
+ {icon}
{fullLabel}
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index fae4c7265..20e665586 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -376,7 +376,7 @@
"upload_documents": {
"title": "Upload Documents",
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.",
- "file_size_limit": "Maximum file size: 50MB per file. Supported formats vary based on your ETL service configuration.",
+ "file_size_limit": "Maximum file size: 50MB per file.",
"upload_limits": "Upload limit: {maxFiles} files, {maxSizeMB}MB total.",
"drop_files": "Drop files here",
"drag_drop": "Drag & drop files here",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 2667a06d1..4c194a5dc 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -360,7 +360,7 @@
"upload_documents": {
"title": "上传文档",
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
- "file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。",
+ "file_size_limit": "最大文件大小:每个文件 50MB。",
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
"drop_files": "放下文件到这里",
"drag_drop": "拖放文件到这里",
From 76e7ddee2f202887d7e31c3457b48157cdea2580 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 18:22:19 +0530
Subject: [PATCH 09/19] chore: ran all linting
---
.../app/routes/notifications_routes.py | 7 +-
.../app/tasks/celery_tasks/document_tasks.py | 4 +-
.../stale_notification_cleanup_task.py | 13 +-
.../components/DocumentsTableShell.tsx | 36 +--
.../new-chat/[[...chat_id]]/page.tsx | 7 +-
.../[search_space_id]/settings/page.tsx | 10 +-
surfsense_web/components/Logo.tsx | 8 +-
.../components/assistant-ui/user-message.tsx | 6 +-
.../layout/providers/LayoutDataProvider.tsx | 4 +-
.../layout/ui/sidebar/ChatListItem.tsx | 49 +++--
.../layout/ui/sidebar/InboxSidebar.tsx | 187 ++++++++--------
.../layout/ui/sidebar/SidebarUserProfile.tsx | 12 +-
.../components/new-chat/chat-share-button.tsx | 4 +-
.../new-chat/image-config-sidebar.tsx | 98 +++++++--
.../new-chat/image-model-selector.tsx | 191 ++++++++--------
.../components/new-chat/model-selector.tsx | 4 +-
surfsense_web/components/pricing.tsx | 7 +-
.../settings/image-model-manager.tsx | 206 ++++++++++++++----
.../components/settings/llm-role-manager.tsx | 202 ++++++++---------
.../components/tool-ui/image/index.tsx | 4 +-
.../contracts/types/new-llm-config.types.ts | 4 +-
.../lib/apis/image-gen-config-api.service.ts | 8 +-
22 files changed, 638 insertions(+), 433 deletions(-)
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index b77a39249..4f80c6529 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -197,10 +197,9 @@ async def list_notifications(
# Filter by search query (case-insensitive title/message search)
if search:
search_term = f"%{search}%"
- search_filter = (
- Notification.title.ilike(search_term)
- | Notification.message.ilike(search_term)
- )
+ search_filter = Notification.title.ilike(
+ search_term
+ ) | Notification.message.ilike(search_term)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
index dfbfea432..81c5dbba2 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
@@ -1005,9 +1005,7 @@ async def _process_circleback_meeting(
# Start Redis heartbeat for stale task detection
_start_heartbeat(notification.id)
- heartbeat_task = asyncio.create_task(
- _run_heartbeat_loop(notification.id)
- )
+ heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
log_entry = await task_logger.log_task_start(
task_name="process_circleback_meeting",
diff --git a/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py b/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
index c47652d2c..f3bbddee0 100644
--- a/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
+++ b/surfsense_backend/app/tasks/celery_tasks/stale_notification_cleanup_task.py
@@ -45,9 +45,8 @@ _redis_client: redis.Redis | None = None
# Error messages shown to users when tasks are interrupted
STALE_SYNC_ERROR_MESSAGE = "Sync was interrupted unexpectedly. Please retry."
-STALE_PROCESSING_ERROR_MESSAGE = (
- "Syncing was interrupted unexpectedly. Please retry."
-)
+STALE_PROCESSING_ERROR_MESSAGE = "Syncing was interrupted unexpectedly. Please retry."
+
def get_redis_client() -> redis.Redis:
"""Get or create Redis client for heartbeat checking."""
@@ -310,9 +309,7 @@ async def _cleanup_stale_document_processing_notifications():
in_progress_rows = result.fetchall()
if not in_progress_rows:
- logger.debug(
- "No in-progress document processing notifications found"
- )
+ logger.debug("No in-progress document processing notifications found")
return
# Check which ones are missing heartbeat keys in Redis
@@ -389,9 +386,7 @@ async def _cleanup_stale_document_processing_notifications():
await session.rollback()
-async def _cleanup_stuck_non_connector_documents(
- session, document_ids: list[int]
-):
+async def _cleanup_stuck_non_connector_documents(session, document_ids: list[int]):
"""
Mark specific non-connector documents stuck in pending/processing as failed.
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
index 8c142edcc..6421db92f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
@@ -372,11 +372,11 @@ export function DocumentsTableShell({
)}
- {columnVisibility.status && (
-
-
-
- )}
+ {columnVisibility.status && (
+
+
+
+ )}
Actions
@@ -544,14 +544,14 @@ export function DocumentsTableShell({
)}
- {columnVisibility.status && (
-
-
-
- Status
-
-
- )}
+ {columnVisibility.status && (
+
+
+
+ Status
+
+
+ )}
Actions
@@ -647,11 +647,11 @@ export function DocumentsTableShell({
)}
- {columnVisibility.status && (
-
-
-
- )}
+ {columnVisibility.status && (
+
+
+
+ )}
)}
{activeSection === "models" && }
- {activeSection === "roles" && }
- {activeSection === "image-models" && (
-
- )}
- {activeSection === "prompts" && }
+ {activeSection === "roles" && }
+ {activeSection === "image-models" && (
+
+ )}
+ {activeSection === "prompts" && }
{activeSection === "public-links" && (
)}
diff --git a/surfsense_web/components/Logo.tsx b/surfsense_web/components/Logo.tsx
index 9f5915777..dfb53eabe 100644
--- a/surfsense_web/components/Logo.tsx
+++ b/surfsense_web/components/Logo.tsx
@@ -4,7 +4,13 @@ import Image from "next/image";
import Link from "next/link";
import { cn } from "@/lib/utils";
-export const Logo = ({ className, disableLink = false }: { className?: string; disableLink?: boolean }) => {
+export const Logo = ({
+ className,
+ disableLink = false,
+}: {
+ className?: string;
+ disableLink?: boolean;
+}) => {
const image = (
{
const UserActionBar: FC = () => {
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
-
+
// Get current message ID
const currentMessageId = useAssistantState(({ message }) => message?.id);
-
+
// Find the last user message ID in the thread (computed once, memoized by selector)
const lastUserMessageId = useAssistantState(({ thread }) => {
const messages = thread.messages;
@@ -117,7 +117,7 @@ const UserActionBar: FC = () => {
// Simple comparison - no iteration needed per message
const isLastUserMessage = currentMessageId === lastUserMessageId;
-
+
// Show edit button only on the last user message and when thread is not running
const canEdit = isLastUserMessage && !isThreadRunning;
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 66d2f419a..ead017a3e 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -526,7 +526,9 @@ export function LayoutDataProvider({
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
// Invalidate thread detail for breadcrumb update
- queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] });
+ queryClient.invalidateQueries({
+ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)],
+ });
} catch (error) {
console.error("Error renaming thread:", error);
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
index ba2989145..157a2ae04 100644
--- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
@@ -1,6 +1,13 @@
"use client";
-import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react";
+import {
+ ArchiveIcon,
+ MessageSquare,
+ MoreHorizontal,
+ PencilIcon,
+ RotateCcwIcon,
+ Trash2,
+} from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@@ -59,26 +66,26 @@ export function ChatListItem({
{t("more_options")}
-
- {onRename && (
- {
- e.stopPropagation();
- onRename();
- }}
- >
-
- {t("rename") || "Rename"}
-
- )}
- {onArchive && (
- {
- e.stopPropagation();
- onArchive();
- }}
- >
- {archived ? (
+
+ {onRename && (
+ {
+ e.stopPropagation();
+ onRename();
+ }}
+ >
+
+ {t("rename") || "Rename"}
+
+ )}
+ {onArchive && (
+ {
+ e.stopPropagation();
+ onArchive();
+ }}
+ >
+ {archived ? (
<>
{t("unarchive") || "Restore"}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index d46651440..3e5521f47 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -219,7 +219,7 @@ export function InboxSidebar({
// Server-side search query (enabled only when user is typing a search)
// Determines which notification types to search based on active tab
- const searchTypeFilter = activeTab === "comments" ? "new_mention" as const : undefined;
+ const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
queryFn: () =>
@@ -288,8 +288,10 @@ export function InboxSidebar({
// Pagination switches based on active tab
const loading = activeTab === "comments" ? mentions.loading : status.loading;
- const loadingMore = activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false);
- const hasMore = activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
+ const loadingMore =
+ activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false);
+ const hasMore =
+ activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
// Get unique connector types from status items for filtering
@@ -319,9 +321,7 @@ export function InboxSidebar({
// When not searching: use Electric real-time items (fast, local)
const filteredItems = useMemo(() => {
// In search mode, use API results
- let items: InboxItem[] = isSearchMode
- ? (searchResponse?.items ?? [])
- : displayItems;
+ let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
// For status tab search results, filter to status-specific types
if (isSearchMode && activeTab === "status") {
@@ -926,49 +926,49 @@ export function InboxSidebar({
-
- {(isSearchMode ? isSearchLoading : loading) ? (
-
- {activeTab === "comments"
- ? /* Comments skeleton: avatar + two-line text + time */
- [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
-
-
-
-
-
+
+ {(isSearchMode ? isSearchLoading : loading) ? (
+
+ {activeTab === "comments"
+ ? /* Comments skeleton: avatar + two-line text + time */
+ [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
+
-
-
- ))
- : /* Status skeleton: status icon circle + two-line text + time */
- [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
-
-
-
-
-
+ ))
+ : /* Status skeleton: status icon circle + two-line text + time */
+ [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
+
-
-
-
-
-
- ))}
-
- ) : filteredItems.length > 0 ? (
-
- {filteredItems.map((item, index) => {
- const isMarkingAsRead = markingAsReadId === item.id;
- // Place prefetch trigger on 5th item from end (only when not searching)
- const isPrefetchTrigger =
- !isSearchMode && hasMore && index === filteredItems.length - 5;
+ ))}
+
+ ) : filteredItems.length > 0 ? (
+
+ {filteredItems.map((item, index) => {
+ const isMarkingAsRead = markingAsReadId === item.id;
+ // Place prefetch trigger on 5th item from end (only when not searching)
+ const isPrefetchTrigger =
+ !isSearchMode && hasMore && index === filteredItems.length - 5;
return (
);
})}
- {/* Fallback trigger at the very end if less than 5 items and not searching */}
- {!isSearchMode && filteredItems.length < 5 && hasMore && (
-
- )}
- {/* Loading more skeletons at the bottom during infinite scroll */}
- {loadingMore && (
- activeTab === "comments"
- ? [80, 60, 90].map((titleWidth, i) => (
-
- ))
- : [70, 85, 55].map((titleWidth, i) => (
-
- ))
- )}
+ {/* Fallback trigger at the very end if less than 5 items and not searching */}
+ {!isSearchMode && filteredItems.length < 5 && hasMore && (
+
+ )}
+ {/* Loading more skeletons at the bottom during infinite scroll */}
+ {loadingMore &&
+ (activeTab === "comments"
+ ? [80, 60, 90].map((titleWidth, i) => (
+
+ ))
+ : [70, 85, 55].map((titleWidth, i) => (
+
+ )))}
+
+ ) : isSearchMode ? (
+
+
+
+ {t("no_results_found") || "No results found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
- ) : isSearchMode ? (
-
-
-
- {t("no_results_found") || "No results found"}
-
-
- {t("try_different_search") || "Try a different search term"}
-
-
) : (
{activeTab === "comments" ? (
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
index 38b3028d2..997482ed3 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
@@ -1,6 +1,16 @@
"use client";
-import { Check, ChevronUp, Languages, Laptop, Loader2, LogOut, Moon, Settings, Sun } from "lucide-react";
+import {
+ Check,
+ ChevronUp,
+ Languages,
+ Laptop,
+ Loader2,
+ LogOut,
+ Moon,
+ Settings,
+ Sun,
+} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx
index 2e04fa3ba..c9e693b21 100644
--- a/surfsense_web/components/new-chat/chat-share-button.tsx
+++ b/surfsense_web/components/new-chat/chat-share-button.tsx
@@ -155,7 +155,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
Share settings
-
- {/* Globe indicator when public snapshots exist - clicks to settings */}
+ {/* Globe indicator when public snapshots exist - clicks to settings */}
{hasPublicSnapshots && (
diff --git a/surfsense_web/components/new-chat/image-config-sidebar.tsx b/surfsense_web/components/new-chat/image-config-sidebar.tsx
index 18f98acb7..be84b0b22 100644
--- a/surfsense_web/components/new-chat/image-config-sidebar.tsx
+++ b/surfsense_web/components/new-chat/image-config-sidebar.tsx
@@ -179,7 +179,17 @@ export function ImageConfigSidebar({
} finally {
setIsSubmitting(false);
}
- }, [mode, isGlobal, config, formData, searchSpaceId, createConfig, updateConfig, updatePreferences, onOpenChange]);
+ }, [
+ mode,
+ isGlobal,
+ config,
+ formData,
+ searchSpaceId,
+ createConfig,
+ updateConfig,
+ updatePreferences,
+ onOpenChange,
+ ]);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
@@ -297,11 +307,16 @@ export function ImageConfigSidebar({
- Auto mode distributes image generation requests across all configured providers for optimal performance and rate limit protection.
+ Auto mode distributes image generation requests across all configured
+ providers for optimal performance and rate limit protection.
-
onOpenChange(false)}>
+ onOpenChange(false)}
+ >
Close
-
Name
+
+ Name
+
{config.name}
{config.description && (
-
Description
+
+ Description
+
{config.description}
)}
@@ -340,20 +359,32 @@ export function ImageConfigSidebar({
-
Provider
+
+ Provider
+
{config.provider}
-
Model
+
+ Model
+
{config.model_name}
- onOpenChange(false)}>
+ onOpenChange(false)}
+ >
Close
-
+
{isSubmitting ? "Loading..." : "Use This Model"}
@@ -379,7 +410,9 @@ export function ImageConfigSidebar({
setFormData((p) => ({ ...p, description: e.target.value }))}
+ onChange={(e) =>
+ setFormData((p) => ({ ...p, description: e.target.value }))
+ }
/>
@@ -390,7 +423,9 @@ export function ImageConfigSidebar({
Provider *
setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
+ onValueChange={(val) =>
+ setFormData((p) => ({ ...p, provider: val, model_name: "" }))
+ }
>
@@ -414,7 +449,11 @@ export function ImageConfigSidebar({
{suggestedModels.length > 0 ? (
-
+
{formData.model_name || "Select or type a model..."}
@@ -424,11 +463,15 @@ export function ImageConfigSidebar({
setFormData((p) => ({ ...p, model_name: val }))}
+ onValueChange={(val) =>
+ setFormData((p) => ({ ...p, model_name: val }))
+ }
/>
- Type a custom model name
+
+ Type a custom model name
+
{suggestedModels.map((m) => (
@@ -440,9 +483,18 @@ export function ImageConfigSidebar({
setModelComboboxOpen(false);
}}
>
-
+
{m.value}
- {m.label}
+
+ {m.label}
+
))}
@@ -454,7 +506,9 @@ export function ImageConfigSidebar({
setFormData((p) => ({ ...p, model_name: e.target.value }))}
+ onChange={(e) =>
+ setFormData((p) => ({ ...p, model_name: e.target.value }))
+ }
/>
)}
@@ -489,14 +543,20 @@ export function ImageConfigSidebar({
setFormData((p) => ({ ...p, api_version: e.target.value }))}
+ onChange={(e) =>
+ setFormData((p) => ({ ...p, api_version: e.target.value }))
+ }
/>
)}
{/* Actions */}
- onOpenChange(false)}>
+ onOpenChange(false)}
+ >
Cancel
Global Image Models
- {filteredGlobal.map((config) => {
- const isSelected = currentConfig?.id === config.id;
- const isAuto = "is_auto_mode" in config && config.is_auto_mode;
- return (
-
handleSelect(config.id)}
- className={cn(
- "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
- isSelected && "bg-accent/80",
- isAuto && "border border-violet-200 dark:border-violet-800/50"
- )}
- >
-
-
- {isAuto ? (
-
- ) : (
-
+ {filteredGlobal.map((config) => {
+ const isSelected = currentConfig?.id === config.id;
+ const isAuto = "is_auto_mode" in config && config.is_auto_mode;
+ return (
+
handleSelect(config.id)}
+ className={cn(
+ "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
+ isSelected && "bg-accent/80",
+ isAuto && "border border-violet-200 dark:border-violet-800/50"
+ )}
+ >
+
+
+ {isAuto ? (
+
+ ) : (
+
+ )}
+
+
+
+ {config.name}
+ {isAuto && (
+
+ Recommended
+
+ )}
+ {isSelected && }
+
+
+ {isAuto ? "Auto load balancing" : config.model_name}
+
+
+ {onEdit && (
+
{
+ e.stopPropagation();
+ setOpen(false);
+ onEdit(config, true);
+ }}
+ />
)}
-
-
- {config.name}
- {isAuto && (
-
- Recommended
-
- )}
- {isSelected && }
-
-
- {isAuto ? "Auto load balancing" : config.model_name}
-
-
- {onEdit && (
- {
- e.stopPropagation();
- setOpen(false);
- onEdit(config, true);
- }}
- />
- )}
-
-
- );
- })}
+
+ );
+ })}
)}
@@ -290,51 +289,49 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
Your Image Models
- {filteredUser.map((config) => {
- const isSelected = currentConfig?.id === config.id;
- return (
- handleSelect(config.id)}
- className={cn(
- "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
- isSelected && "bg-accent/80"
- )}
- >
-
-
-
-
-
-
- {config.name}
- {isSelected && (
-
- )}
-
-
- {config.model_name}
-
-
- {onEdit && (
-
{
- e.stopPropagation();
- setOpen(false);
- onEdit(config, false);
- }}
- >
-
-
+ {filteredUser.map((config) => {
+ const isSelected = currentConfig?.id === config.id;
+ return (
+
handleSelect(config.id)}
+ className={cn(
+ "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
+ isSelected && "bg-accent/80"
)}
-
-
- );
- })}
+ >
+
+
+
+
+
+
+ {config.name}
+ {isSelected && }
+
+
+ {config.model_name}
+
+
+ {onEdit && (
+
{
+ e.stopPropagation();
+ setOpen(false);
+ onEdit(config, false);
+ }}
+ >
+
+
+ )}
+
+
+ );
+ })}
>
)}
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index 148028df2..ec1143e04 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -392,8 +392,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
)}
- {/* Add New Config Button */}
-
+ {/* Add New Config Button */}
+
- {plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}
+ {plan.billingText ??
+ (isNaN(Number(plan.price))
+ ? ""
+ : isMonthly
+ ? "billed monthly"
+ : "billed annually")}
diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx
index 4e3f1840a..e87cc9a95 100644
--- a/surfsense_web/components/settings/image-model-manager.tsx
+++ b/surfsense_web/components/settings/image-model-manager.tsx
@@ -95,16 +95,29 @@ const item = {
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
// Image gen config atoms
- const { mutateAsync: createConfig, isPending: isCreating, error: createError } =
- useAtomValue(createImageGenConfigMutationAtom);
- const { mutateAsync: updateConfig, isPending: isUpdating, error: updateError } =
- useAtomValue(updateImageGenConfigMutationAtom);
- const { mutateAsync: deleteConfig, isPending: isDeleting, error: deleteError } =
- useAtomValue(deleteImageGenConfigMutationAtom);
+ const {
+ mutateAsync: createConfig,
+ isPending: isCreating,
+ error: createError,
+ } = useAtomValue(createImageGenConfigMutationAtom);
+ const {
+ mutateAsync: updateConfig,
+ isPending: isUpdating,
+ error: updateError,
+ } = useAtomValue(updateImageGenConfigMutationAtom);
+ const {
+ mutateAsync: deleteConfig,
+ isPending: isDeleting,
+ error: deleteError,
+ } = useAtomValue(deleteImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
- const { data: userConfigs, isFetching: configsLoading, error: fetchError, refetch: refreshConfigs } =
- useAtomValue(imageGenConfigsAtom);
+ const {
+ data: userConfigs,
+ isFetching: configsLoading,
+ error: fetchError,
+ refetch: refreshConfigs,
+ } = useAtomValue(imageGenConfigsAtom);
const { data: globalConfigs = [], isFetching: globalLoading } =
useAtomValue(globalImageGenConfigsAtom);
const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom);
@@ -249,7 +262,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
data: {
image_generation_config_id:
typeof selectedPrefId === "string"
- ? selectedPrefId ? parseInt(selectedPrefId) : undefined
+ ? selectedPrefId
+ ? parseInt(selectedPrefId)
+ : undefined
: selectedPrefId,
},
});
@@ -289,7 +304,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Errors */}
{errors.map((err) => (
-
+
{err?.message}
@@ -304,7 +324,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
- {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image model(s)
+ {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global
+ image model(s)
{" "}
available from your administrator.
@@ -342,18 +363,27 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{globalConfigs.length > 0 && (
<>
- Global
+
+ Global
+
{globalConfigs.map((c) => {
const isAuto = "is_auto_mode" in c && c.is_auto_mode;
return (
{isAuto ? (
-
- AUTO
+
+
+ AUTO
) : (
-
+
{c.provider}
)}
@@ -366,11 +396,15 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)}
{(userConfigs?.length ?? 0) > 0 && (
<>
- Your Models
+
+ Your Models
+
{userConfigs?.map((c) => (
- {c.provider}
+
+ {c.provider}
+
{c.name}
({c.model_name})
@@ -382,10 +416,23 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{hasPrefChanges && (
-
+
{isSavingPref ? "Saving..." : "Save"}
- { setSelectedPrefId(preferences.image_generation_config_id ?? ""); setHasPrefChanges(false); }} className="text-xs h-8">
+ {
+ setSelectedPrefId(preferences.image_generation_config_id ?? "");
+ setHasPrefChanges(false);
+ }}
+ className="text-xs h-8"
+ >
Reset
@@ -409,7 +456,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
Your Image Models
-
+
Add Image Model
@@ -435,7 +485,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{userConfigs?.map((config) => (
-
+
@@ -448,8 +503,13 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
-
{config.name}
-
+
+ {config.name}
+
+
{config.provider}
@@ -457,7 +517,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{config.model_name}
{config.description && (
-
{config.description}
+
+ {config.description}
+
)}
@@ -469,7 +531,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
- openEditDialog(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground">
+ openEditDialog(config)}
+ className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
+ >
@@ -479,7 +546,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
- setConfigToDelete(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive">
+ setConfigToDelete(config)}
+ className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
+ >
@@ -501,15 +573,30 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)}
{/* Create/Edit Dialog */}
- { if (!open) { setIsDialogOpen(false); setEditingConfig(null); resetForm(); } }}>
+ {
+ if (!open) {
+ setIsDialogOpen(false);
+ setEditingConfig(null);
+ resetForm();
+ }
+ }}
+ >
- {editingConfig ? : }
+ {editingConfig ? (
+
+ ) : (
+
+ )}
{editingConfig ? "Edit Image Model" : "Add Image Model"}
- {editingConfig ? "Update your image generation model" : "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"}
+ {editingConfig
+ ? "Update your image generation model"
+ : "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"}
@@ -541,7 +628,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
Provider *
setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
+ onValueChange={(val) =>
+ setFormData((p) => ({ ...p, provider: val, model_name: "" }))
+ }
>
@@ -565,7 +654,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{suggestedModels.length > 0 ? (
-
+
{formData.model_name || "Select or type a model..."}
@@ -579,7 +672,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
/>
- Type a custom model name
+
+ Type a custom model name
+
{suggestedModels.map((m) => (
@@ -591,7 +686,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
setModelComboboxOpen(false);
}}
>
-
+
{m.value}
{m.label}
@@ -650,14 +750,24 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{ setIsDialogOpen(false); setEditingConfig(null); resetForm(); }}
+ onClick={() => {
+ setIsDialogOpen(false);
+ setEditingConfig(null);
+ resetForm();
+ }}
>
Cancel
{isSubmitting ? : null}
{editingConfig ? "Save Changes" : "Create & Use"}
@@ -668,7 +778,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Delete Confirmation */}
- !open && setConfigToDelete(null)}>
+ !open && setConfigToDelete(null)}
+ >
@@ -676,13 +789,28 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
Delete Image Model
- Are you sure you want to delete {configToDelete?.name} ?
+ Are you sure you want to delete{" "}
+ {configToDelete?.name} ?
Cancel
-
- {isDeleting ? <> Deleting> : <> Delete>}
+
+ {isDeleting ? (
+ <>
+
+ Deleting
+ >
+ ) : (
+ <>
+
+ Delete
+ >
+ )}
diff --git a/surfsense_web/components/settings/llm-role-manager.tsx b/surfsense_web/components/settings/llm-role-manager.tsx
index 22e3d8e08..dac68a358 100644
--- a/surfsense_web/components/settings/llm-role-manager.tsx
+++ b/surfsense_web/components/settings/llm-role-manager.tsx
@@ -255,15 +255,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
)}
- {/* Role Assignment Cards */}
- {availableConfigs.length > 0 && (
-
- {Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
- const IconComponent = role.icon;
- const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
- const assignedConfig = availableConfigs.find(
- (config) => config.id === currentAssignment
- );
+ {/* Role Assignment Cards */}
+ {availableConfigs.length > 0 && (
+
+ {Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
+ const IconComponent = role.icon;
+ const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
+ const assignedConfig = availableConfigs.find(
+ (config) => config.id === currentAssignment
+ );
return (
-
-
- Assign LLM Configuration:
-
-
handleRoleAssignment(`${key}_llm_id`, value)}
- >
-
-
-
-
-
- Unassigned
-
+
+
+ Assign LLM Configuration:
+
+
handleRoleAssignment(`${key}_llm_id`, value)}
+ >
+
+
+
+
+
+ Unassigned
+
- {/* Global Configurations */}
- {globalConfigs.length > 0 && (
- <>
-
- Global Configurations
-
- {globalConfigs.map((config) => {
- const isAutoMode =
- "is_auto_mode" in config && config.is_auto_mode;
- return (
-
-
- {isAutoMode ? (
-
-
- AUTO
-
- ) : (
-
- {config.provider}
-
- )}
- {config.name}
- {!isAutoMode && (
-
- ({config.model_name})
-
- )}
- {isAutoMode ? (
-
- Recommended
-
- ) : (
-
- 🌐 Global
-
- )}
-
-
- );
- })}
- >
- )}
+ {/* Global Configurations */}
+ {globalConfigs.length > 0 && (
+ <>
+
+ Global Configurations
+
+ {globalConfigs.map((config) => {
+ const isAutoMode =
+ "is_auto_mode" in config && config.is_auto_mode;
+ return (
+
+
+ {isAutoMode ? (
+
+
+ AUTO
+
+ ) : (
+
+ {config.provider}
+
+ )}
+ {config.name}
+ {!isAutoMode && (
+
+ ({config.model_name})
+
+ )}
+ {isAutoMode ? (
+
+ Recommended
+
+ ) : (
+
+ 🌐 Global
+
+ )}
+
+
+ );
+ })}
+ >
+ )}
- {/* Custom Configurations */}
- {newLLMConfigs.length > 0 && (
- <>
-
- Your Configurations
-
- {newLLMConfigs
- .filter(
- (config) => config.id && config.id.toString().trim() !== ""
- )
- .map((config) => (
-
-
-
- {config.provider}
-
- {config.name}
-
- ({config.model_name})
-
-
-
- ))}
- >
- )}
-
-
-
+ {/* Custom Configurations */}
+ {newLLMConfigs.length > 0 && (
+ <>
+
+ Your Configurations
+
+ {newLLMConfigs
+ .filter(
+ (config) => config.id && config.id.toString().trim() !== ""
+ )
+ .map((config) => (
+
+
+
+ {config.provider}
+
+ {config.name}
+
+ ({config.model_name})
+
+
+
+ ))}
+ >
+ )}
+
+
+
{assignedConfig && (
{isGenerated &&
}
diff --git a/surfsense_web/contracts/types/new-llm-config.types.ts b/surfsense_web/contracts/types/new-llm-config.types.ts
index 3f0d39e5a..b99df1022 100644
--- a/surfsense_web/contracts/types/new-llm-config.types.ts
+++ b/surfsense_web/contracts/types/new-llm-config.types.ts
@@ -213,9 +213,7 @@ export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
export const updateImageGenConfigRequest = z.object({
id: z.number(),
- data: imageGenerationConfig
- .omit({ id: true, created_at: true, search_space_id: true })
- .partial(),
+ data: imageGenerationConfig.omit({ id: true, created_at: true, search_space_id: true }).partial(),
});
export const updateImageGenConfigResponse = imageGenerationConfig;
diff --git a/surfsense_web/lib/apis/image-gen-config-api.service.ts b/surfsense_web/lib/apis/image-gen-config-api.service.ts
index 84aeed3d8..379edfa53 100644
--- a/surfsense_web/lib/apis/image-gen-config-api.service.ts
+++ b/surfsense_web/lib/apis/image-gen-config-api.service.ts
@@ -32,11 +32,9 @@ class ImageGenConfigApiService {
const msg = parsed.error.issues.map((i) => i.message).join(", ");
throw new ValidationError(`Invalid request: ${msg}`);
}
- return baseApiService.post(
- `/api/v1/image-generation-configs`,
- createImageGenConfigResponse,
- { body: parsed.data }
- );
+ return baseApiService.post(`/api/v1/image-generation-configs`, createImageGenConfigResponse, {
+ body: parsed.data,
+ });
};
/**
From 21eec210cc2613dd328b04f3b4ad335b272670a3 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 18:35:29 +0530
Subject: [PATCH 10/19] feat: disable mobile sidebar tooltip & make searchspace
list vertical on mobile view
---
.../layout/ui/icon-rail/SearchSpaceAvatar.tsx | 32 +++++++++-----
.../layout/ui/sidebar/MobileSidebar.tsx | 43 ++++++++++---------
2 files changed, 45 insertions(+), 30 deletions(-)
diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
index 1786c9c5e..3459ebb3d 100644
--- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
+++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
@@ -21,6 +21,7 @@ interface SearchSpaceAvatarProps {
onDelete?: () => void;
onSettings?: () => void;
size?: "sm" | "md";
+ disableTooltip?: boolean;
}
/**
@@ -64,6 +65,7 @@ export function SearchSpaceAvatar({
onDelete,
onSettings,
size = "md",
+ disableTooltip = false,
}: SearchSpaceAvatarProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
@@ -114,16 +116,22 @@ export function SearchSpaceAvatar({
if (onDelete || onSettings) {
return (
-
-
-
- {avatarButton}
-
-
-
- {tooltipContent}
-
-
+ {disableTooltip ? (
+
+ {avatarButton}
+
+ ) : (
+
+
+
+ {avatarButton}
+
+
+
+ {tooltipContent}
+
+
+ )}
{onSettings && (
@@ -150,6 +158,10 @@ export function SearchSpaceAvatar({
}
// No context menu needed
+ if (disableTooltip) {
+ return avatarButton;
+ }
+
return (
{avatarButton}
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index 71d85f600..12980e4d5 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -2,6 +2,7 @@
import { Menu, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
@@ -97,15 +98,16 @@ export function MobileSidebar({
return (
-
+
Navigation
- {/* Horizontal Search Spaces Rail */}
-
-
- {searchSpaces.map((space) => (
-
+ {/* Vertical Search Spaces Rail - left side */}
+
+
+
+ {searchSpaces.map((space) => (
1}
@@ -116,23 +118,24 @@ export function MobileSidebar({
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
}
size="md"
+ disableTooltip
/>
-
- ))}
-
-
- Add search space
-
-
+ ))}
+
+
+ Add search space
+
+
+
- {/* Sidebar Content */}
-
+ {/* Sidebar Content - right side */}
+
Date: Fri, 6 Feb 2026 18:59:52 +0530
Subject: [PATCH 11/19] feat: add disableTooltip prop to sidebar components and
streamline mobile sidebar button functionality
---
.../layout/ui/sidebar/InboxSidebar.tsx | 29 ++++++++-----------
.../layout/ui/sidebar/MobileSidebar.tsx | 6 ++--
.../components/layout/ui/sidebar/Sidebar.tsx | 28 ++++++++++--------
.../ui/sidebar/SidebarCollapseButton.tsx | 23 ++++++++++-----
.../layout/ui/sidebar/SidebarHeader.tsx | 6 ++--
5 files changed, 51 insertions(+), 41 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 3e5521f47..353788c59 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -576,6 +576,18 @@ export function InboxSidebar({
+ {/* Back button - mobile only */}
+ {isMobile && (
+ onOpenChange(false)}
+ >
+
+ {t("close") || "Close"}
+
+ )}
{t("inbox") || "Inbox"}
@@ -816,23 +828,6 @@ export function InboxSidebar({
{t("mark_all_read") || "Mark all as read"}
- {/* Close button - mobile only */}
- {isMobile && (
-
-
- onOpenChange(false)}
- >
-
- {t("close") || "Close"}
-
-
- {t("close") || "Close"}
-
- )}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index 12980e4d5..7ffa8baa4 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Menu, Plus } from "lucide-react";
+import { PanelRightClose, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@@ -44,7 +44,7 @@ interface MobileSidebarProps {
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
return (
-
+
Open menu
);
@@ -139,6 +139,7 @@ export function MobileSidebar({
onOpenChange(false)}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
@@ -164,6 +165,7 @@ export function MobileSidebar({
setTheme={setTheme}
className="w-full border-none"
isLoadingChats={isLoadingChats}
+ disableTooltips
/>
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index 7b53fdc6a..5b1fb26f5 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -50,6 +50,7 @@ interface SidebarProps {
setTheme?: (theme: "light" | "dark" | "system") => void;
className?: string;
isLoadingChats?: boolean;
+ disableTooltips?: boolean;
}
export function Sidebar({
@@ -78,6 +79,7 @@ export function Sidebar({
setTheme,
className,
isLoadingChats = false,
+ disableTooltips = false,
}: SidebarProps) {
const t = useTranslations("sidebar");
@@ -95,23 +97,25 @@ export function Sidebar({
{})}
+ disableTooltip={disableTooltips}
/>
) : (
-
-
+
+
+
{})}
+ disableTooltip={disableTooltips}
/>
-
- {})}
- />
-
+
)}
{/* New chat button */}
@@ -138,7 +142,7 @@ export function Sidebar({
{isCollapsed ? (
) : (
-
+
{/* Shared Chats Section - takes only space needed, max 50% */}
void;
+ disableTooltip?: boolean;
}
-export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) {
+export function SidebarCollapseButton({ isCollapsed, onToggle, disableTooltip = false }: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar");
+ const button = (
+
+ {isCollapsed ? : }
+
+ {isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
+
+
+ );
+
+ if (disableTooltip) {
+ return button;
+ }
+
return (
-
- {isCollapsed ? : }
-
- {isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
-
-
+ {button}
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
index 28c359e64..288f27452 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
@@ -35,14 +35,14 @@ export function SidebarHeader({
const searchSpaceId = params.search_space_id as string;
return (
-
+
From f7278f75f460bdded4dd58a97a20c50be259449e Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 19:18:40 +0530
Subject: [PATCH 12/19] feat: implement long-press dropdown menu for mobile
avatars and streamline tooltip usage in sidebar components
---
.../layout/ui/icon-rail/SearchSpaceAvatar.tsx | 110 +++++++++++++++---
.../layout/ui/sidebar/InboxSidebar.tsx | 60 +++++++---
.../components/layout/ui/sidebar/Sidebar.tsx | 42 +++++--
3 files changed, 172 insertions(+), 40 deletions(-)
diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
index 3459ebb3d..d807d5898 100644
--- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
+++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
@@ -1,6 +1,7 @@
"use client";
import { Settings, Trash2, Users } from "lucide-react";
+import { useCallback, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
ContextMenu,
@@ -9,6 +10,13 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -73,6 +81,35 @@ export function SearchSpaceAvatar({
const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
+ // Long-press state for mobile
+ const [longPressMenuOpen, setLongPressMenuOpen] = useState(false);
+ const longPressTimer = useRef | null>(null);
+ const touchMoved = useRef(false);
+
+ const handleTouchStart = useCallback(() => {
+ touchMoved.current = false;
+ longPressTimer.current = setTimeout(() => {
+ if (!touchMoved.current) {
+ setLongPressMenuOpen(true);
+ }
+ }, 500);
+ }, []);
+
+ const handleTouchMove = useCallback(() => {
+ touchMoved.current = true;
+ if (longPressTimer.current) {
+ clearTimeout(longPressTimer.current);
+ longPressTimer.current = null;
+ }
+ }, []);
+
+ const handleTouchEnd = useCallback(() => {
+ if (longPressTimer.current) {
+ clearTimeout(longPressTimer.current);
+ longPressTimer.current = null;
+ }
+ }, []);
+
const tooltipContent = (
{name}
@@ -112,26 +149,67 @@ export function SearchSpaceAvatar({
);
+ const menuItems = (
+ <>
+ {onSettings && (
+
+
+ {tCommon("settings")}
+
+ )}
+ {onSettings && onDelete &&
}
+ {onDelete && isOwner && (
+
+
+ {tCommon("delete")}
+
+ )}
+ {onDelete && !isOwner && (
+
+
+ {t("leave")}
+
+ )}
+ >
+ );
+
// If delete or settings handlers are provided, wrap with context menu
if (onDelete || onSettings) {
+ // Mobile: use long-press triggered DropdownMenu
+ if (disableTooltip) {
+ return (
+
+
+
+ {avatarButton}
+
+
+
+ {menuItems}
+
+
+ );
+ }
+
+ // Desktop: use right-click ContextMenu + Tooltip
return (
- {disableTooltip ? (
-
- {avatarButton}
-
- ) : (
-
-
-
- {avatarButton}
-
-
-
- {tooltipContent}
-
-
- )}
+
+
+
+ {avatarButton}
+
+
+
+ {tooltipContent}
+
+
{onSettings && (
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 353788c59..84784d71b 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -594,20 +594,15 @@ export function InboxSidebar({
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? (
<>
-
-
- setFilterDrawerOpen(true)}
- >
-
- {t("filter") || "Filter"}
-
-
- {t("filter") || "Filter"}
-
+ setFilterDrawerOpen(true)}
+ >
+
+ {t("filter") || "Filter"}
+
)}
+ {isMobile ? (
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+ ) : (
+ )}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
@@ -976,6 +984,29 @@ export function InboxSidebar({
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
>
+ {isMobile ? (
+ handleItemClick(item)}
+ disabled={isMarkingAsRead}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
+ >
+ {getStatusIcon(item)}
+
+
+ {item.title}
+
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+ ) : (
+ )}
{/* Time and unread dot - fixed width to prevent content shift */}
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index 5b1fb26f5..49f5a1de6 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -149,8 +149,18 @@ export function Sidebar({
defaultOpen={true}
fillHeight={false}
className="shrink-0 max-h-[50%] flex flex-col"
- action={
- onViewAllSharedChats ? (
+ action={
+ onViewAllSharedChats ? (
+ disableTooltips ? (
+
+
+
+ ) : (
- ) : undefined
- }
- >
- {isLoadingChats ? (
+ )
+ ) : undefined
+ }
+ >
+ {isLoadingChats ? (
@@ -210,8 +221,18 @@ export function Sidebar({
title={t("chats")}
defaultOpen={true}
fillHeight={true}
- action={
- onViewAllPrivateChats ? (
+ action={
+ onViewAllPrivateChats ? (
+ disableTooltips ? (
+
+
+
+ ) : (
- ) : undefined
- }
+ )
+ ) : undefined
+ }
>
{isLoadingChats ? (
From e3dc2ad39f0979f5e87b45b96591465dfa25a61b Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 19:25:01 +0530
Subject: [PATCH 13/19] fix: update icon styling in DocumentsTableShell and
enhance mobile rendering conditions in OnboardingTour
---
.../documents/(manage)/components/DocumentsTableShell.tsx | 2 +-
surfsense_web/components/onboarding-tour.tsx | 6 ++++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
index 6421db92f..a44295ec0 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
@@ -467,7 +467,7 @@ export function DocumentsTableShell({
className="flex flex-col items-center gap-4 max-w-md px-4 text-center"
>
-
+
{t("no_documents")}
diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx
index 12773c932..7053f00cf 100644
--- a/surfsense_web/components/onboarding-tour.tsx
+++ b/surfsense_web/components/onboarding-tour.tsx
@@ -10,6 +10,7 @@ import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
+import { useIsMobile } from "@/hooks/use-mobile";
import { fetchThreads } from "@/lib/chat/thread-persistence";
interface TourStep {
@@ -393,6 +394,7 @@ function TourTooltip({
}
export function OnboardingTour() {
+ const isMobile = useIsMobile();
const [isActive, setIsActive] = useState(false);
const [stepIndex, setStepIndex] = useState(0);
const [targetEl, setTargetEl] = useState
(null);
@@ -685,8 +687,8 @@ export function OnboardingTour() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isActive, user?.id]);
- // Don't render if not active or not mounted
- if (!mounted || !isActive) {
+ // Don't render on mobile, or if not active or not mounted
+ if (isMobile || !mounted || !isActive) {
return null;
}
From 9d071d686a1dcb594cfe5ceea1da920c29d4b318 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 19:43:14 +0530
Subject: [PATCH 14/19] feat: add viewport configuration for mobile keyboard
handling and update chat page height calculations for better responsiveness
---
.../new-chat/[[...chat_id]]/page.tsx | 6 +++---
surfsense_web/app/layout.tsx | 16 +++++++++++++++-
surfsense_web/components/assistant-ui/thread.tsx | 5 ++++-
3 files changed, 22 insertions(+), 5 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 a00afb3df..22085e064 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
@@ -1408,7 +1408,7 @@ export default function NewChatPage() {
// Show loading state only when loading an existing thread
if (isInitializing) {
return (
-
+
{/* User message */}
@@ -1449,7 +1449,7 @@ export default function NewChatPage() {
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) {
return (
-
+
Failed to load chat
{/*
Disabled for now */}
-
+
}
diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx
index af25193ef..047baa27b 100644
--- a/surfsense_web/app/layout.tsx
+++ b/surfsense_web/app/layout.tsx
@@ -1,4 +1,4 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
@@ -19,6 +19,20 @@ const roboto = Roboto({
variable: "--font-roboto",
});
+/**
+ * Viewport configuration for mobile keyboard handling.
+ * - interactiveWidget: 'resizes-content' tells mobile browsers (especially Chrome Android)
+ * to resize the CSS layout viewport when the virtual keyboard opens, so sticky elements
+ * (like the chat input bar) stay visible above the keyboard.
+ * - viewportFit: 'cover' enables env(safe-area-inset-*) for notched/home-indicator devices.
+ */
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover",
+ interactiveWidget: "resizes-content",
+};
+
export const metadata: Metadata = {
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
description:
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 4c2165c0a..dc30cfe40 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -120,7 +120,10 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
}}
/>
-
+
!thread.isEmpty}>
From bacda8cb082146f88ea7ff8fda395f06874b0a31 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 19:45:22 +0530
Subject: [PATCH 15/19] chore: ran linting
---
.../layout/ui/icon-rail/SearchSpaceAvatar.tsx | 4 +-
.../layout/ui/sidebar/InboxSidebar.tsx | 188 +++++++++---------
.../components/layout/ui/sidebar/Sidebar.tsx | 148 +++++++-------
.../ui/sidebar/SidebarCollapseButton.tsx | 14 +-
4 files changed, 176 insertions(+), 178 deletions(-)
diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
index d807d5898..1686f3a46 100644
--- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
+++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
@@ -190,9 +190,7 @@ export function SearchSpaceAvatar({
{avatarButton}
-
- {menuItems}
-
+ {menuItems}
);
}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 84784d71b..b6caed330 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -594,15 +594,15 @@ export function InboxSidebar({
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? (
<>
- setFilterDrawerOpen(true)}
- >
-
- {t("filter") || "Filter"}
-
+ setFilterDrawerOpen(true)}
+ >
+
+ {t("filter") || "Filter"}
+
)}
- {isMobile ? (
-
-
- {t("mark_all_read") || "Mark all as read"}
-
- ) : (
-
-
-
-
- {t("mark_all_read") || "Mark all as read"}
-
-
-
- {t("mark_all_read") || "Mark all as read"}
-
-
- )}
+ {isMobile ? (
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+ ) : (
+
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+ )}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
@@ -984,61 +984,61 @@ export function InboxSidebar({
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
>
- {isMobile ? (
- handleItemClick(item)}
- disabled={isMarkingAsRead}
- className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
- >
- {getStatusIcon(item)}
-
-
- {item.title}
-
-
- {convertRenderedToDisplay(item.message)}
-
-
-
- ) : (
-
-
- handleItemClick(item)}
- disabled={isMarkingAsRead}
- className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
- >
- {getStatusIcon(item)}
-
-
- {item.title}
-
-
- {convertRenderedToDisplay(item.message)}
-
-
-
-
-
- {item.title}
-
- {convertRenderedToDisplay(item.message)}
-
-
-
- )}
+ {isMobile ? (
+ handleItemClick(item)}
+ disabled={isMarkingAsRead}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
+ >
+ {getStatusIcon(item)}
+
+
+ {item.title}
+
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+ ) : (
+
+
+ handleItemClick(item)}
+ disabled={isMarkingAsRead}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
+ >
+ {getStatusIcon(item)}
+
+
+ {item.title}
+
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+
+ {item.title}
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+ )}
{/* Time and unread dot - fixed width to prevent content shift */}
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index 49f5a1de6..883fa5890 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -101,21 +101,21 @@ export function Sidebar({
/>
) : (
-
-
-
-
+ {})}
- disableTooltip={disableTooltips}
+ onSettings={onSettings}
+ onManageMembers={onManageMembers}
/>
+
+ {})}
+ disableTooltip={disableTooltips}
+ />
+
-
)}
{/* New chat button */}
@@ -149,38 +149,38 @@ export function Sidebar({
defaultOpen={true}
fillHeight={false}
className="shrink-0 max-h-[50%] flex flex-col"
- action={
- onViewAllSharedChats ? (
- disableTooltips ? (
-
-
-
- ) : (
-
-
-
-
-
-
-
- {t("view_all_shared_chats") || "View all shared chats"}
-
-
- )
- ) : undefined
- }
- >
- {isLoadingChats ? (
+ action={
+ onViewAllSharedChats ? (
+ disableTooltips ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ {t("view_all_shared_chats") || "View all shared chats"}
+
+
+ )
+ ) : undefined
+ }
+ >
+ {isLoadingChats ? (
@@ -221,36 +221,36 @@ export function Sidebar({
title={t("chats")}
defaultOpen={true}
fillHeight={true}
- action={
- onViewAllPrivateChats ? (
- disableTooltips ? (
-
-
-
- ) : (
-
-
-
-
-
-
-
- {t("view_all_private_chats") || "View all private chats"}
-
-
- )
- ) : undefined
- }
+ action={
+ onViewAllPrivateChats ? (
+ disableTooltips ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ {t("view_all_private_chats") || "View all private chats"}
+
+
+ )
+ ) : undefined
+ }
>
{isLoadingChats ? (
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx
index 5a02bc09c..44f05249c 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx
@@ -11,15 +11,17 @@ interface SidebarCollapseButtonProps {
disableTooltip?: boolean;
}
-export function SidebarCollapseButton({ isCollapsed, onToggle, disableTooltip = false }: SidebarCollapseButtonProps) {
+export function SidebarCollapseButton({
+ isCollapsed,
+ onToggle,
+ disableTooltip = false,
+}: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar");
const button = (
{isCollapsed ? : }
-
- {isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
-
+ {isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
);
@@ -29,9 +31,7 @@ export function SidebarCollapseButton({ isCollapsed, onToggle, disableTooltip =
return (
-
- {button}
-
+ {button}
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
From 306c8e3c54b277bf24e0f19faa45b9bd01ec8b85 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 23:34:35 +0530
Subject: [PATCH 16/19] feat: enhance sidebar components for mobile
responsiveness by conditionally rendering tooltips and updating button
functionality
---
.../ui/sidebar/AllPrivateChatsSidebar.tsx | 52 ++++++++++++-------
.../ui/sidebar/AllSharedChatsSidebar.tsx | 52 ++++++++++++-------
.../layout/ui/sidebar/MobileSidebar.tsx | 18 ++++++-
3 files changed, 82 insertions(+), 40 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index ed5cec00e..1d4f590bd 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -32,6 +32,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { useIsMobile } from "@/hooks/use-mobile";
import {
deleteThread,
fetchThreads,
@@ -57,6 +58,7 @@ export function AllPrivateChatsSidebar({
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
+ const isMobile = useIsMobile();
const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0])
@@ -338,25 +340,37 @@ export function AllPrivateChatsSidebar({
isBusy && "opacity-50 pointer-events-none"
)}
>
-
-
- handleThreadClick(thread.id)}
- disabled={isBusy}
- className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
- >
-
- {thread.title || "New Chat"}
-
-
-
-
- {t("updated") || "Updated"}:{" "}
- {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
-
-
-
+ {isMobile ? (
+ handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+ ) : (
+
+
+ handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+
+
+
+ {t("updated") || "Updated"}:{" "}
+ {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
+
+
+
+ )}
-
-
- handleThreadClick(thread.id)}
- disabled={isBusy}
- className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
- >
-
- {thread.title || "New Chat"}
-
-
-
-
- {t("updated") || "Updated"}:{" "}
- {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
-
-
-
+ {isMobile ? (
+ handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+ ) : (
+
+
+ handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+
+
+
+ {t("updated") || "Updated"}:{" "}
+ {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
+
+
+
+ )}
{
+ onOpenChange(false);
+ onViewAllSharedChats();
+ }
+ : undefined
+ }
+ onViewAllPrivateChats={
+ onViewAllPrivateChats
+ ? () => {
+ onOpenChange(false);
+ onViewAllPrivateChats();
+ }
+ : undefined
+ }
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
From 342e3a6a955bd10fb8c94b903dce52021e13292e Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 23:35:02 +0530
Subject: [PATCH 17/19] chore: linting
---
.../layout/ui/sidebar/MobileSidebar.tsx | 32 +++++++++----------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index 53421230e..377bf65f5 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -153,22 +153,22 @@ export function MobileSidebar({
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
- onViewAllSharedChats={
- onViewAllSharedChats
- ? () => {
- onOpenChange(false);
- onViewAllSharedChats();
- }
- : undefined
- }
- onViewAllPrivateChats={
- onViewAllPrivateChats
- ? () => {
- onOpenChange(false);
- onViewAllPrivateChats();
- }
- : undefined
- }
+ onViewAllSharedChats={
+ onViewAllSharedChats
+ ? () => {
+ onOpenChange(false);
+ onViewAllSharedChats();
+ }
+ : undefined
+ }
+ onViewAllPrivateChats={
+ onViewAllPrivateChats
+ ? () => {
+ onOpenChange(false);
+ onViewAllPrivateChats();
+ }
+ : undefined
+ }
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
From bf340f9b95a72dd1c1dd50f5dac0775bc7cf2538 Mon Sep 17 00:00:00 2001
From: Eric Lammertsma
Date: Fri, 6 Feb 2026 13:56:51 -0500
Subject: [PATCH 18/19] UX: remove logs option from SidebarHeader component
---
.../components/layout/ui/sidebar/SidebarHeader.tsx | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
index 28c359e64..6c747b9eb 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react";
+import { ChevronsUpDown, Settings, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@@ -56,10 +56,6 @@ export function SidebarHeader({
{t("manage_members")}
- router.push(`/dashboard/${searchSpaceId}/logs`)}>
-
- {t("logs")}
-
From 179dd18e45caf45002b646f7cc0035d4eb151ed0 Mon Sep 17 00:00:00 2001
From: Eric Lammertsma
Date: Fri, 6 Feb 2026 15:01:14 -0500
Subject: [PATCH 19/19] UX: replace doc delete confirmation with descriptive
text
---
.../documents/(manage)/components/RowActions.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
index ec355f576..eb44d114a 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
@@ -9,6 +9,7 @@ import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
+ AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
@@ -205,7 +206,10 @@ export function RowActions({
- Are you sure?
+ Delete document?
+
+ This action cannot be undone. This will permanently delete this document from your search space.
+
Cancel