This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-02-06 14:02:51 -08:00
commit 20a13df7e7
45 changed files with 1554 additions and 641 deletions

View file

@ -178,11 +178,28 @@ async def create_documents_file_upload(
session, unique_identifier_hash session, unique_identifier_hash
) )
if existing: if existing:
# Clean up temp file for duplicates if DocumentStatus.is_state(existing.status, DocumentStatus.READY):
# True duplicate — content already indexed, skip
os.unlink(temp_path) os.unlink(temp_path)
skipped_duplicates += 1 skipped_duplicates += 1
continue 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) # Create pending document (visible immediately in UI via ElectricSQL)
document = Document( document = Document(
search_space_id=search_space_id, search_space_id=search_space_id,

View file

@ -144,6 +144,9 @@ async def list_notifications(
before_date: str | None = Query( before_date: str | None = Query(
None, description="Get notifications before this ISO date (for pagination)" None, description="Get notifications before this ISO date (for pagination)"
), ),
search: str | None = Query(
None, description="Search notifications by title or message (case-insensitive)"
),
limit: int = Query(50, ge=1, le=100, description="Number of items to return"), limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
offset: int = Query(0, ge=0, description="Number of items to skip"), offset: int = Query(0, ge=0, description="Number of items to skip"),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
@ -191,6 +194,15 @@ async def list_notifications(
detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)", detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
) from None ) from None
# 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)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
# Get total count # Get total count
total_result = await session.execute(count_query) total_result = await session.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0

View file

@ -1,6 +1,8 @@
"""Celery tasks for document processing.""" """Celery tasks for document processing."""
import asyncio
import logging import logging
import os
from uuid import UUID from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@ -17,6 +19,79 @@ from app.tasks.document_processors import (
logger = logging.getLogger(__name__) 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(): def get_celery_session_maker():
""" """
@ -44,8 +119,6 @@ def process_extension_document_task(
search_space_id: ID of the search space search_space_id: ID of the search space
user_id: ID of the user user_id: ID of the user
""" """
import asyncio
# Create a new event loop for this task # Create a new event loop for this task
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(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 search_space_id: ID of the search space
user_id: ID of the user user_id: ID of the user
""" """
import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(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( log_entry = await task_logger.log_task_start(
task_name="process_youtube_video", task_name="process_youtube_video",
source="document_processor", 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( result = await add_youtube_video_document(
session, url, search_space_id, user_id session, url, search_space_id, user_id, notification=notification
) )
if result: 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}") logger.error(f"Error processing YouTube video: {e!s}")
raise 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) @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 search_space_id: ID of the search space
user_id: ID of the user user_id: ID of the user
""" """
import asyncio
import os
import traceback import traceback
logger.info( logger.info(
@ -336,7 +413,7 @@ def process_file_upload_task(
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.error( logger.error(
f"[process_file_upload] File does not exist: {file_path}. " 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 return
@ -370,8 +447,6 @@ async def _process_file_upload(
file_path: str, filename: str, search_space_id: int, user_id: str file_path: str, filename: str, search_space_id: int, user_id: str
): ):
"""Process file upload with new session.""" """Process file upload with new session."""
import os
from app.tasks.document_processors.file_processors import process_file_in_background from app.tasks.document_processors.file_processors import process_file_in_background
logger.info(f"[_process_file_upload] Starting async processing for: {filename}") 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'}" 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( log_entry = await task_logger.log_task_start(
task_name="process_file_upload", task_name="process_file_upload",
source="document_processor", source="document_processor",
@ -535,6 +614,10 @@ async def _process_file_upload(
) )
logger.error(error_message) logger.error(error_message)
raise 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) @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 search_space_id: ID of the search space
user_id: ID of the user user_id: ID of the user
""" """
import asyncio
import os
import traceback import traceback
logger.info( logger.info(
@ -573,7 +654,7 @@ def process_file_upload_with_document_task(
if not os.path.exists(temp_path): if not os.path.exists(temp_path):
logger.error( logger.error(
f"[process_file_upload_with_document] File does not exist: {temp_path}. " 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 # Mark document as failed since file is missing
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@ -582,7 +663,7 @@ def process_file_upload_with_document_task(
loop.run_until_complete( loop.run_until_complete(
_mark_document_failed( _mark_document_failed(
document_id, document_id,
"File not found - temp file may have been cleaned up", "File not found. Please re-upload the file.",
) )
) )
finally: finally:
@ -640,8 +721,6 @@ async def _process_file_with_document(
- Processes the file (parsing, embedding, chunking) - Processes the file (parsing, embedding, chunking)
- Updates document to 'ready' on success or 'failed' on error - Updates document to 'ready' on success or 'failed' on error
""" """
import os
from app.db import Document, DocumentStatus from app.db import Document, DocumentStatus
from app.tasks.document_processors.base import get_current_timestamp from app.tasks.document_processors.base import get_current_timestamp
from app.tasks.document_processors.file_processors import ( 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( log_entry = await task_logger.log_task_start(
task_name="process_file_upload_with_document", task_name="process_file_upload_with_document",
source="document_processor", source="document_processor",
@ -822,6 +914,10 @@ async def _process_file_with_document(
raise raise
finally: finally:
# Stop heartbeat — key deleted on success, expires on crash
heartbeat_task.cancel()
_stop_heartbeat(notification.id)
# Clean up temp file # Clean up temp file
if os.path.exists(temp_path): if os.path.exists(temp_path):
try: try:
@ -856,8 +952,6 @@ def process_circleback_meeting_task(
search_space_id: ID of the search space search_space_id: ID of the search space
connector_id: ID of the Circleback connector (for deletion support) connector_id: ID of the Circleback connector (for deletion support)
""" """
import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
@ -897,6 +991,7 @@ async def _process_circleback_meeting(
# Create notification if user_id is available # Create notification if user_id is available
notification = None notification = None
heartbeat_task = None
if user_id: if user_id:
notification = ( notification = (
await NotificationService.document_processing.notify_processing_started( await NotificationService.document_processing.notify_processing_started(
@ -908,6 +1003,10 @@ 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( log_entry = await task_logger.log_task_start(
task_name="process_circleback_meeting", task_name="process_circleback_meeting",
source="circleback_webhook", source="circleback_webhook",
@ -1000,3 +1099,9 @@ async def _process_circleback_meeting(
logger.error(f"Error processing Circleback meeting: {e!s}") logger.error(f"Error processing Circleback meeting: {e!s}")
raise raise
finally:
# Stop heartbeat — key deleted on success, expires on crash
if heartbeat_task:
heartbeat_task.cancel()
if notification:
_stop_heartbeat(notification.id)

View file

@ -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 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. 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 It handles two notification types:
to connectors with stale notifications. 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: Detection mechanism:
- Active indexing tasks set a Redis key with TTL (2 minutes) as a heartbeat - Active tasks set a Redis key with TTL (2 minutes) as a heartbeat
- If the task crashes, the Redis key expires automatically - 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 - This cleanup task checks for in-progress notifications without a Redis heartbeat key
- Such notifications are marked as failed with O(1) batch UPDATE - 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 import contextlib
@ -36,8 +43,9 @@ logger = logging.getLogger(__name__)
# Redis client for checking heartbeats # Redis client for checking heartbeats
_redis_client: redis.Redis | None = None _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_SYNC_ERROR_MESSAGE = "Sync was interrupted unexpectedly. Please retry."
STALE_PROCESSING_ERROR_MESSAGE = "Syncing was interrupted unexpectedly. Please retry."
def get_redis_client() -> redis.Redis: def get_redis_client() -> redis.Redis:
@ -70,14 +78,13 @@ def get_celery_session_maker():
@celery_app.task(name="cleanup_stale_indexing_notifications") @celery_app.task(name="cleanup_stale_indexing_notifications")
def cleanup_stale_indexing_notifications_task(): 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: Handles two notification types:
- Have type = 'connector_indexing' 1. connector_indexing connector sync tasks
- Have metadata.status = 'in_progress' 2. document_processing manual file uploads, YouTube videos, etc.
- Do NOT have a corresponding Redis heartbeat key (meaning task crashed)
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. Also marks associated pending/processing documents as failed.
""" """
import asyncio import asyncio
@ -87,6 +94,7 @@ def cleanup_stale_indexing_notifications_task():
try: try:
loop.run_until_complete(_cleanup_stale_notifications()) loop.run_until_complete(_cleanup_stale_notifications())
loop.run_until_complete(_cleanup_stale_document_processing_notifications())
finally: finally:
loop.close() loop.close()
@ -269,3 +277,186 @@ async def _cleanup_stuck_documents(session, connector_ids: list[int]):
exc_info=True, exc_info=True,
) )
# Don't raise - let the notification cleanup continue even if document cleanup fails # 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

View file

@ -61,7 +61,11 @@ def get_youtube_video_id(url: str) -> str | None:
async def add_youtube_video_document( 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: ) -> Document:
""" """
Process a YouTube video URL, extract transcripts, and store as a 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) url: YouTube video URL (supports standard, shortened, and embed formats)
search_space_id: ID of the search space to add the document to search_space_id: ID of the search space to add the document to
user_id: ID of the user 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: Returns:
Document: The created document object 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! await session.commit() # Document visible in UI now with pending status!
is_new_document = True 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}") logging.info(f"Created pending document for YouTube video {video_id}")
# ======================================================================= # =======================================================================

View file

@ -35,9 +35,9 @@ export function DocumentTypeChip({ type, className }: { type: string; className?
const chip = ( const chip = (
<span <span
className={`inline-flex items-center gap-1.5 rounded bg-muted/40 px-2 py-1 text-xs text-muted-foreground max-w-full overflow-hidden ${className ?? ""}`} className={`inline-flex items-center gap-1.5 rounded-full bg-accent/80 px-2.5 py-1 text-xs font-medium text-accent-foreground/70 shadow-sm max-w-full overflow-hidden ${className ?? ""}`}
> >
<span className="opacity-80 flex-shrink-0">{icon}</span> <span className="flex-shrink-0">{icon}</span>
<span ref={textRef} className="truncate min-w-0"> <span ref={textRef} className="truncate min-w-0">
{fullLabel} {fullLabel}
</span> </span>

View file

@ -178,7 +178,7 @@ export function DocumentsFilters({
<div className="relative"> <div className="relative">
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search types..." placeholder="Search types"
value={typeSearchQuery} value={typeSearchQuery}
onChange={(e) => setTypeSearchQuery(e.target.value)} onChange={(e) => setTypeSearchQuery(e.target.value)}
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0" className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"

View file

@ -3,6 +3,7 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { import {
AlertCircle, AlertCircle,
BadgeInfo,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
@ -372,7 +373,7 @@ export function DocumentsTableShell({
</TableHead> </TableHead>
)} )}
{columnVisibility.status && ( {columnVisibility.status && (
<TableHead className="w-20 text-center"> <TableHead className="w-14 text-center">
<Skeleton className="h-3 w-12 mx-auto" /> <Skeleton className="h-3 w-12 mx-auto" />
</TableHead> </TableHead>
)} )}
@ -414,7 +415,7 @@ export function DocumentsTableShell({
</TableCell> </TableCell>
)} )}
{columnVisibility.status && ( {columnVisibility.status && (
<TableCell className="w-20 py-2.5 text-center"> <TableCell className="w-14 py-2.5 text-center">
<Skeleton className="h-5 w-5 mx-auto rounded-full" /> <Skeleton className="h-5 w-5 mx-auto rounded-full" />
</TableCell> </TableCell>
)} )}
@ -466,7 +467,7 @@ export function DocumentsTableShell({
className="flex flex-col items-center gap-4 max-w-md px-4 text-center" className="flex flex-col items-center gap-4 max-w-md px-4 text-center"
> >
<div className="rounded-full bg-muted/50 p-4"> <div className="rounded-full bg-muted/50 p-4">
<FileX className="h-8 w-8 text-muted-foreground/60" /> <FileX className="h-8 w-8 text-muted-foreground" />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<h3 className="text-lg font-semibold">{t("no_documents")}</h3> <h3 className="text-lg font-semibold">{t("no_documents")}</h3>
@ -544,8 +545,11 @@ export function DocumentsTableShell({
</TableHead> </TableHead>
)} )}
{columnVisibility.status && ( {columnVisibility.status && (
<TableHead className="w-20 text-center"> <TableHead className="w-14">
<span className="text-sm font-medium text-muted-foreground/70">Status</span> <span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<BadgeInfo size={14} className="opacity-60 text-muted-foreground" />
Status
</span>
</TableHead> </TableHead>
)} )}
<TableHead className="w-10"> <TableHead className="w-10">
@ -644,7 +648,7 @@ export function DocumentsTableShell({
</TableCell> </TableCell>
)} )}
{columnVisibility.status && ( {columnVisibility.status && (
<TableCell className="w-20 py-2.5 text-center"> <TableCell className="w-14 py-2.5 text-center">
<StatusIndicator status={doc.status} /> <StatusIndicator status={doc.status} />
</TableCell> </TableCell>
)} )}

View file

@ -9,6 +9,7 @@ import {
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
@ -205,7 +206,10 @@ export function RowActions({
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}> <AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogTitle>Delete document?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete this document from your search space.
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>

View file

@ -140,6 +140,9 @@ export default function DocumentsTable() {
} }
}); });
setPageIndex(0); setPageIndex(0);
// Clear selections when filter changes — selected IDs from the previous
// filter view are no longer visible and would cause misleading bulk actions
setSelectedIds(new Set());
}; };
const onBulkDelete = async () => { const onBulkDelete = async () => {

View file

@ -844,7 +844,12 @@ export default function NewChatPage() {
}); });
// Invalidate thread detail for breadcrumb update // Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)], queryKey: [
"threads",
String(searchSpaceId),
"detail",
String(titleData.threadId),
],
}); });
} }
break; break;
@ -1403,7 +1408,7 @@ export default function NewChatPage() {
// Show loading state only when loading an existing thread // Show loading state only when loading an existing thread
if (isInitializing) { if (isInitializing) {
return ( return (
<div className="flex h-[calc(100vh-64px)] flex-col bg-background px-4"> <div className="flex h-[calc(100dvh-64px)] flex-col bg-background px-4">
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8"> <div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
{/* User message */} {/* User message */}
<div className="flex justify-end"> <div className="flex justify-end">
@ -1444,7 +1449,7 @@ export default function NewChatPage() {
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation) // For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) { if (!threadId && urlChatId > 0) {
return ( return (
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4"> <div className="flex h-[calc(100dvh-64px)] flex-col items-center justify-center gap-4">
<div className="text-destructive">Failed to load chat</div> <div className="text-destructive">Failed to load chat</div>
<button <button
type="button" type="button"
@ -1469,7 +1474,7 @@ export default function NewChatPage() {
<SaveMemoryToolUI /> <SaveMemoryToolUI />
<RecallMemoryToolUI /> <RecallMemoryToolUI />
{/* <WriteTodosToolUI /> Disabled for now */} {/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden"> <div className="flex flex-col h-[calc(100dvh-64px)] overflow-hidden">
<Thread <Thread
messageThinkingSteps={messageThinkingSteps} messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />} header={<ChatHeader searchSpaceId={searchSpaceId} />}

View file

@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import "./globals.css"; import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next"; import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google"; import { Roboto } from "next/font/google";
@ -19,6 +19,20 @@ const roboto = Roboto({
variable: "--font-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 = { export const metadata: Metadata = {
title: "SurfSense Customizable AI Research & Knowledge Management Assistant", title: "SurfSense Customizable AI Research & Knowledge Management Assistant",
description: description:

View file

@ -4,7 +4,13 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { cn } from "@/lib/utils"; 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 image = (
<Image <Image
src="/icon-128.svg" src="/icon-128.svg"

View file

@ -120,7 +120,10 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
}} }}
/> />
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6"> <ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<ThreadScrollToBottom /> <ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}> <AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both"> <div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">

View file

@ -526,7 +526,9 @@ export function LayoutDataProvider({
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
// Invalidate thread detail for breadcrumb update // 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) { } catch (error) {
console.error("Error renaming thread:", error); console.error("Error renaming thread:", error);
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { Settings, Trash2, Users } from "lucide-react"; import { Settings, Trash2, Users } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import {
ContextMenu, ContextMenu,
@ -9,6 +10,13 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -21,6 +29,7 @@ interface SearchSpaceAvatarProps {
onDelete?: () => void; onDelete?: () => void;
onSettings?: () => void; onSettings?: () => void;
size?: "sm" | "md"; size?: "sm" | "md";
disableTooltip?: boolean;
} }
/** /**
@ -64,6 +73,7 @@ export function SearchSpaceAvatar({
onDelete, onDelete,
onSettings, onSettings,
size = "md", size = "md",
disableTooltip = false,
}: SearchSpaceAvatarProps) { }: SearchSpaceAvatarProps) {
const t = useTranslations("searchSpace"); const t = useTranslations("searchSpace");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@ -71,6 +81,35 @@ export function SearchSpaceAvatar({
const initials = getInitials(name); const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; 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<ReturnType<typeof setTimeout> | 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 = ( const tooltipContent = (
<div className="flex flex-col"> <div className="flex flex-col">
<span>{name}</span> <span>{name}</span>
@ -110,8 +149,53 @@ export function SearchSpaceAvatar({
</button> </button>
); );
const menuItems = (
<>
{onSettings && (
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</DropdownMenuItem>
)}
{onSettings && onDelete && <DropdownMenuSeparator />}
{onDelete && isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</DropdownMenuItem>
)}
{onDelete && !isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("leave")}
</DropdownMenuItem>
)}
</>
);
// If delete or settings handlers are provided, wrap with context menu // If delete or settings handlers are provided, wrap with context menu
if (onDelete || onSettings) { if (onDelete || onSettings) {
// Mobile: use long-press triggered DropdownMenu
if (disableTooltip) {
return (
<DropdownMenu open={longPressMenuOpen} onOpenChange={setLongPressMenuOpen}>
<DropdownMenuTrigger asChild>
<div
className="inline-block"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{avatarButton}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">{menuItems}</DropdownMenuContent>
</DropdownMenu>
);
}
// Desktop: use right-click ContextMenu + Tooltip
return ( return (
<ContextMenu> <ContextMenu>
<Tooltip> <Tooltip>
@ -150,6 +234,10 @@ export function SearchSpaceAvatar({
} }
// No context menu needed // No context menu needed
if (disableTooltip) {
return avatarButton;
}
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger> <TooltipTrigger asChild>{avatarButton}</TooltipTrigger>

View file

@ -27,10 +27,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useIsMobile } from "@/hooks/use-mobile";
import { import {
deleteThread, deleteThread,
fetchThreads, fetchThreads,
@ -56,6 +58,7 @@ export function AllPrivateChatsSidebar({
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isMobile = useIsMobile();
const currentChatId = Array.isArray(params.chat_id) const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0]) ? Number(params.chat_id[0])
@ -303,8 +306,16 @@ export function AllPrivateChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-8"> <div className="space-y-1">
<Spinner size="md" className="text-muted-foreground" /> {[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div
key={`skeleton-${i}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div>
))}
</div> </div>
) : error ? ( ) : error ? (
<div className="text-center py-8 text-sm text-destructive"> <div className="text-center py-8 text-sm text-destructive">
@ -329,6 +340,17 @@ export function AllPrivateChatsSidebar({
isBusy && "opacity-50 pointer-events-none" isBusy && "opacity-50 pointer-events-none"
)} )}
> >
{isMobile ? (
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -348,6 +370,7 @@ export function AllPrivateChatsSidebar({
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
<DropdownMenu <DropdownMenu
open={openDropdownId === thread.id} open={openDropdownId === thread.id}

View file

@ -27,10 +27,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useIsMobile } from "@/hooks/use-mobile";
import { import {
deleteThread, deleteThread,
fetchThreads, fetchThreads,
@ -56,6 +58,7 @@ export function AllSharedChatsSidebar({
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isMobile = useIsMobile();
const currentChatId = Array.isArray(params.chat_id) const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0]) ? Number(params.chat_id[0])
@ -303,8 +306,16 @@ export function AllSharedChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-8"> <div className="space-y-1">
<Spinner size="md" className="text-muted-foreground" /> {[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div
key={`skeleton-${i}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div>
))}
</div> </div>
) : error ? ( ) : error ? (
<div className="text-center py-8 text-sm text-destructive"> <div className="text-center py-8 text-sm text-destructive">
@ -329,6 +340,17 @@ export function AllSharedChatsSidebar({
isBusy && "opacity-50 pointer-events-none" isBusy && "opacity-50 pointer-events-none"
)} )}
> >
{isMobile ? (
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -348,6 +370,7 @@ export function AllSharedChatsSidebar({
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
<DropdownMenu <DropdownMenu
open={openDropdownId === thread.id} open={openDropdownId === thread.id}

View file

@ -1,6 +1,13 @@
"use client"; "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 { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
AlertCircle, AlertCircle,
@ -19,7 +20,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
@ -41,6 +42,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -52,7 +54,10 @@ import {
isPageLimitExceededMetadata, isPageLimitExceededMetadata,
} from "@/contracts/types/inbox.types"; } from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarContextSafe } from "../../hooks"; import { useSidebarContextSafe } from "../../hooks";
@ -179,7 +184,9 @@ export function InboxSidebar({
}: InboxSidebarProps) { }: InboxSidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const router = useRouter(); const router = useRouter();
const params = useParams();
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
// Comments collapsed state (desktop only, when docked) // Comments collapsed state (desktop only, when docked)
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
@ -187,12 +194,22 @@ export function InboxSidebar({
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const debouncedSearch = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearch.trim();
const [activeTab, setActiveTab] = useState<InboxTab>("comments"); const [activeTab, setActiveTab] = useState<InboxTab>("comments");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all"); const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedConnector, setSelectedConnector] = useState<string | null>(null); const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Dropdown state for filter menu (desktop only) // Dropdown state for filter menu (desktop only)
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
// Scroll shadow state for connector list
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
// Drawer state for filter menu (mobile only) // Drawer state for filter menu (mobile only)
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null); const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
@ -200,6 +217,24 @@ export function InboxSidebar({
// Prefetch trigger ref - placed on item near the end // Prefetch trigger ref - placed on item near the end
const prefetchTriggerRef = useRef<HTMLDivElement>(null); const prefetchTriggerRef = useRef<HTMLDivElement>(null);
// 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 { data: searchResponse, isLoading: isSearchLoading } = useQuery({
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
queryFn: () =>
notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId ?? undefined,
type: searchTypeFilter,
search: debouncedSearch.trim(),
limit: 50,
},
}),
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh)
enabled: isSearchMode && open,
});
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
@ -234,17 +269,11 @@ export function InboxSidebar({
} }
}, [activeTab]); }, [activeTab]);
// Both tabs now derive items from status (all types), so use status for pagination // Each tab uses its own data source for independent pagination
const { loading, loadingMore = false, hasMore = false, loadMore } = status; // Comments tab: uses mentions data source (fetches only mention/reply types from server)
const commentsItems = mentions.items;
// Comments tab: mentions and comment replies // Status tab: filters status data source (fetches all types) to status-specific types
const commentsItems = useMemo(
() =>
status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"),
[status.items]
);
// Status tab: connector indexing, document processing, page limit exceeded, connector deletion
const statusItems = useMemo( const statusItems = useMemo(
() => () =>
status.items.filter( status.items.filter(
@ -257,6 +286,14 @@ export function InboxSidebar({
[status.items] [status.items]
); );
// 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 loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
// Get unique connector types from status items for filtering // Get unique connector types from status items for filtering
const uniqueConnectorTypes = useMemo(() => { const uniqueConnectorTypes = useMemo(() => {
const connectorTypes = new Set<string>(); const connectorTypes = new Set<string>();
@ -279,9 +316,23 @@ export function InboxSidebar({
// Get items for current tab // Get items for current tab
const displayItems = activeTab === "comments" ? commentsItems : statusItems; const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// Filter items based on filter type, connector filter, and search query // Filter items based on filter type, connector filter, and search mode
// When searching: use server-side API results (searches ALL notifications)
// When not searching: use Electric real-time items (fast, local)
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let items = displayItems; // In search mode, use API results
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
// For status tab search results, filter to status-specific types
if (isSearchMode && activeTab === "status") {
items = items.filter(
(item) =>
item.type === "connector_indexing" ||
item.type === "document_processing" ||
item.type === "page_limit_exceeded" ||
item.type === "connector_deletion"
);
}
// Apply read/unread filter // Apply read/unread filter
if (activeFilter === "unread") { if (activeFilter === "unread") {
@ -302,22 +353,14 @@ export function InboxSidebar({
}); });
} }
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
items = items.filter(
(item) =>
item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
);
}
return items; return items;
}, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]); }, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
// Intersection Observer for infinite scroll with prefetching // Intersection Observer for infinite scroll with prefetching
// Only active when not searching (search results are client-side filtered) // Re-runs when active tab changes so each tab gets its own pagination
// Disabled during server-side search (search results are not paginated via infinite scroll)
useEffect(() => { useEffect(() => {
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return; if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
@ -338,17 +381,11 @@ export function InboxSidebar({
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery]); }, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
// Unread counts derived from filtered items // Unread counts from server-side accurate totals (passed via props)
const unreadCommentsCount = useMemo( const unreadCommentsCount = mentions.unreadCount;
() => commentsItems.filter((item) => !item.read).length, const unreadStatusCount = status.unreadCount;
[commentsItems]
);
const unreadStatusCount = useMemo(
() => statusItems.filter((item) => !item.read).length,
[statusItems]
);
const handleItemClick = useCallback( const handleItemClick = useCallback(
async (item: InboxItem) => { async (item: InboxItem) => {
@ -539,14 +576,24 @@ export function InboxSidebar({
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Back button - mobile only */}
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2> <h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */} {/* Mobile: Button that opens bottom drawer */}
{isMobile ? ( {isMobile ? (
<> <>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -556,9 +603,6 @@ export function InboxSidebar({
<ListFilter className="h-4 w-4 text-muted-foreground" /> <ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span> <span className="sr-only">{t("filter") || "Filter"}</span>
</Button> </Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<Drawer <Drawer
open={filterDrawerOpen} open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen} onOpenChange={setFilterDrawerOpen}
@ -725,6 +769,14 @@ export function InboxSidebar({
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2"> <DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
{t("connectors") || "Connectors"} {t("connectors") || "Connectors"}
</DropdownMenuLabel> </DropdownMenuLabel>
<div
className="relative max-h-[30vh] overflow-y-auto -mb-1"
onScroll={handleConnectorScroll}
style={{
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setSelectedConnector(null)} onClick={() => setSelectedConnector(null)}
className="flex items-center justify-between" className="flex items-center justify-between"
@ -748,11 +800,24 @@ export function InboxSidebar({
{selectedConnector === connector.type && <Check className="h-4 w-4" />} {selectedConnector === connector.type && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</div>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
{isMobile ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -770,22 +835,6 @@ export function InboxSidebar({
{t("mark_all_read") || "Mark all as read"} {t("mark_all_read") || "Mark all as read"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Close button - mobile only */}
{isMobile && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("close") || "Close"}</TooltipContent>
</Tooltip>
)} )}
{/* Dock/Undock button - desktop only */} {/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && ( {!isMobile && onDockedChange && (
@ -881,17 +930,48 @@ export function InboxSidebar({
</Tabs> </Tabs>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{loading ? ( {(isSearchMode ? isSearchLoading : loading) ? (
<div className="flex items-center justify-center py-8"> <div className="space-y-2">
<Spinner size="md" className="text-muted-foreground" /> {activeTab === "comments"
? /* Comments skeleton: avatar + two-line text + time */
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
<div
key={`skeleton-comment-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[70%] rounded" />
</div>
<Skeleton className="h-3 w-6 shrink-0 rounded" />
</div>
))
: /* Status skeleton: status icon circle + two-line text + time */
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div
key={`skeleton-status-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[60%] rounded" />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Skeleton className="h-3 w-6 rounded" />
<Skeleton className="h-2 w-2 rounded-full" />
</div>
</div>
))}
</div> </div>
) : filteredItems.length > 0 ? ( ) : filteredItems.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{filteredItems.map((item, index) => { {filteredItems.map((item, index) => {
const isMarkingAsRead = markingAsReadId === item.id; const isMarkingAsRead = markingAsReadId === item.id;
// Place prefetch trigger on 5th item from end (only if not searching) // Place prefetch trigger on 5th item from end (only when not searching)
const isPrefetchTrigger = const isPrefetchTrigger =
!searchQuery && hasMore && index === filteredItems.length - 5; !isSearchMode && hasMore && index === filteredItems.length - 5;
return ( return (
<div <div
@ -904,6 +984,29 @@ export function InboxSidebar({
isMarkingAsRead && "opacity-50 pointer-events-none" isMarkingAsRead && "opacity-50 pointer-events-none"
)} )}
> >
{isMobile ? (
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -935,6 +1038,7 @@ export function InboxSidebar({
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
{/* Time and unread dot - fixed width to prevent content shift */} {/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10"> <div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
@ -947,11 +1051,43 @@ export function InboxSidebar({
); );
})} })}
{/* Fallback trigger at the very end if less than 5 items and not searching */} {/* Fallback trigger at the very end if less than 5 items and not searching */}
{!searchQuery && filteredItems.length < 5 && hasMore && ( {!isSearchMode && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" /> <div ref={prefetchTriggerRef} className="h-1" />
)} )}
{/* Loading more skeletons at the bottom during infinite scroll */}
{loadingMore &&
(activeTab === "comments"
? [80, 60, 90].map((titleWidth, i) => (
<div
key={`loading-more-comment-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[70%] rounded" />
</div> </div>
) : searchQuery ? ( <Skeleton className="h-3 w-6 shrink-0 rounded" />
</div>
))
: [70, 85, 55].map((titleWidth, i) => (
<div
key={`loading-more-status-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[60%] rounded" />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Skeleton className="h-3 w-6 rounded" />
<Skeleton className="h-2 w-2 rounded-full" />
</div>
</div>
)))}
</div>
) : isSearchMode ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> <Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { Menu, Plus } from "lucide-react"; import { PanelRightClose, Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar"; import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
@ -43,7 +44,7 @@ interface MobileSidebarProps {
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) { export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
return ( return (
<Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}> <Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}>
<Menu className="h-5 w-5" /> <PanelRightClose className="h-5 w-5" />
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
</Button> </Button>
); );
@ -97,15 +98,16 @@ export function MobileSidebar({
return ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-[300px] p-0 flex flex-col"> <SheetContent side="left" className="w-[340px] p-0 flex flex-row gap-0 [&>button]:hidden">
<SheetTitle className="sr-only">Navigation</SheetTitle> <SheetTitle className="sr-only">Navigation</SheetTitle>
{/* Horizontal Search Spaces Rail */} {/* Vertical Search Spaces Rail - left side */}
<div className="shrink-0 border-b bg-muted/40 px-2 py-2 overflow-hidden"> <div className="flex h-full w-14 shrink-0 flex-col items-center bg-muted/40 border-r">
<div className="flex items-center gap-2 px-1 py-1 overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/20"> <ScrollArea className="w-full flex-1">
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
{searchSpaces.map((space) => ( {searchSpaces.map((space) => (
<div key={space.id} className="shrink-0">
<SearchSpaceAvatar <SearchSpaceAvatar
key={space.id}
name={space.name} name={space.name}
isActive={space.id === activeSearchSpaceId} isActive={space.id === activeSearchSpaceId}
isShared={space.memberCount > 1} isShared={space.memberCount > 1}
@ -116,8 +118,8 @@ export function MobileSidebar({
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
} }
size="md" size="md"
disableTooltip
/> />
</div>
))} ))}
<Button <Button
variant="ghost" variant="ghost"
@ -129,13 +131,15 @@ export function MobileSidebar({
<span className="sr-only">Add search space</span> <span className="sr-only">Add search space</span>
</Button> </Button>
</div> </div>
</ScrollArea>
</div> </div>
{/* Sidebar Content */} {/* Sidebar Content - right side */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden flex flex-col">
<Sidebar <Sidebar
searchSpace={searchSpace} searchSpace={searchSpace}
isCollapsed={false} isCollapsed={false}
onToggleCollapse={() => onOpenChange(false)}
navItems={navItems} navItems={navItems}
onNavItemClick={handleNavItemClick} onNavItemClick={handleNavItemClick}
chats={chats} chats={chats}
@ -149,8 +153,22 @@ export function MobileSidebar({
onChatRename={onChatRename} onChatRename={onChatRename}
onChatDelete={onChatDelete} onChatDelete={onChatDelete}
onChatArchive={onChatArchive} onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats} onViewAllSharedChats={
onViewAllPrivateChats={onViewAllPrivateChats} onViewAllSharedChats
? () => {
onOpenChange(false);
onViewAllSharedChats();
}
: undefined
}
onViewAllPrivateChats={
onViewAllPrivateChats
? () => {
onOpenChange(false);
onViewAllPrivateChats();
}
: undefined
}
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
@ -161,6 +179,7 @@ export function MobileSidebar({
setTheme={setTheme} setTheme={setTheme}
className="w-full border-none" className="w-full border-none"
isLoadingChats={isLoadingChats} isLoadingChats={isLoadingChats}
disableTooltips
/> />
</div> </div>
</SheetContent> </SheetContent>

View file

@ -50,6 +50,7 @@ interface SidebarProps {
setTheme?: (theme: "light" | "dark" | "system") => void; setTheme?: (theme: "light" | "dark" | "system") => void;
className?: string; className?: string;
isLoadingChats?: boolean; isLoadingChats?: boolean;
disableTooltips?: boolean;
} }
export function Sidebar({ export function Sidebar({
@ -78,6 +79,7 @@ export function Sidebar({
setTheme, setTheme,
className, className,
isLoadingChats = false, isLoadingChats = false,
disableTooltips = false,
}: SidebarProps) { }: SidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
@ -95,20 +97,22 @@ export function Sidebar({
<SidebarCollapseButton <SidebarCollapseButton
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})} onToggle={onToggleCollapse ?? (() => {})}
disableTooltip={disableTooltips}
/> />
</div> </div>
) : ( ) : (
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b"> <div className="flex h-14 shrink-0 items-center gap-0 px-1 border-b">
<SidebarHeader <SidebarHeader
searchSpace={searchSpace} searchSpace={searchSpace}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
/> />
<div className=""> <div className="shrink-0">
<SidebarCollapseButton <SidebarCollapseButton
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})} onToggle={onToggleCollapse ?? (() => {})}
disableTooltip={disableTooltips}
/> />
</div> </div>
</div> </div>
@ -138,7 +142,7 @@ export function Sidebar({
{isCollapsed ? ( {isCollapsed ? (
<div className="flex-1 w-[60px]" /> <div className="flex-1 w-[60px]" />
) : ( ) : (
<div className="flex-1 flex flex-col gap-1 py-2 w-[240px] min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col gap-1 py-2 w-full min-h-0 overflow-hidden">
{/* Shared Chats Section - takes only space needed, max 50% */} {/* Shared Chats Section - takes only space needed, max 50% */}
<SidebarSection <SidebarSection
title={t("shared_chats")} title={t("shared_chats")}
@ -147,6 +151,16 @@ export function Sidebar({
className="shrink-0 max-h-[50%] flex flex-col" className="shrink-0 max-h-[50%] flex flex-col"
action={ action={
onViewAllSharedChats ? ( onViewAllSharedChats ? (
disableTooltips ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllSharedChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -162,6 +176,7 @@ export function Sidebar({
{t("view_all_shared_chats") || "View all shared chats"} {t("view_all_shared_chats") || "View all shared chats"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)
) : undefined ) : undefined
} }
> >
@ -208,6 +223,16 @@ export function Sidebar({
fillHeight={true} fillHeight={true}
action={ action={
onViewAllPrivateChats ? ( onViewAllPrivateChats ? (
disableTooltips ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -223,6 +248,7 @@ export function Sidebar({
{t("view_all_private_chats") || "View all private chats"} {t("view_all_private_chats") || "View all private chats"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)
) : undefined ) : undefined
} }
> >

View file

@ -8,21 +8,30 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
interface SidebarCollapseButtonProps { interface SidebarCollapseButtonProps {
isCollapsed: boolean; isCollapsed: boolean;
onToggle: () => void; onToggle: () => void;
disableTooltip?: boolean;
} }
export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) { export function SidebarCollapseButton({
isCollapsed,
onToggle,
disableTooltip = false,
}: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const button = (
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
<span className="sr-only">{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}</span>
</Button>
);
if (disableTooltip) {
return button;
}
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>{button}</TooltipTrigger>
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
<span className="sr-only">
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side={isCollapsed ? "right" : "bottom"}> <TooltipContent side={isCollapsed ? "right" : "bottom"}>
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`} {isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
</TooltipContent> </TooltipContent>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react"; import { ChevronsUpDown, Settings, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -35,14 +35,14 @@ export function SidebarHeader({
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
return ( return (
<div className={cn("flex shrink-0 items-center", className)}> <div className={cn("flex min-w-0 flex-1 items-center", className)}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
"flex h-auto items-center justify-between gap-2 overflow-hidden py-1.5 font-semibold", "flex h-auto w-full items-center justify-between gap-1 overflow-hidden py-1.5 font-semibold",
isCollapsed ? "w-10" : "w-50" isCollapsed && "w-10"
)} )}
> >
<span className="truncate text-base"> <span className="truncate text-base">
@ -56,10 +56,6 @@ export function SidebarHeader({
<Users className="mr-2 h-4 w-4" /> <Users className="mr-2 h-4 w-4" />
{t("manage_members")} {t("manage_members")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
<Logs className="mr-2 h-4 w-4" />
{t("logs")}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}> <DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />

View file

@ -1,6 +1,16 @@
"use client"; "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 { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { import {

View file

@ -179,7 +179,17 @@ export function ImageConfigSidebar({
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [mode, isGlobal, config, formData, searchSpaceId, createConfig, updateConfig, updatePreferences, onOpenChange]); }, [
mode,
isGlobal,
config,
formData,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]);
const handleUseGlobalConfig = useCallback(async () => { const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return; if (!config || !isGlobal) return;
@ -297,11 +307,16 @@ export function ImageConfigSidebar({
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5"> <Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
<Shuffle className="size-4 text-violet-500" /> <Shuffle className="size-4 text-violet-500" />
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400"> <AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
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.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="flex gap-3 pt-4 border-t border-border/50"> <div className="flex gap-3 pt-4 border-t border-border/50">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}> <Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close Close
</Button> </Button>
<Button <Button
@ -327,12 +342,16 @@ export function ImageConfigSidebar({
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Name</div> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Name
</div>
<p className="text-sm font-medium">{config.name}</p> <p className="text-sm font-medium">{config.name}</p>
</div> </div>
{config.description && ( {config.description && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Description</div> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p> <p className="text-sm text-muted-foreground">{config.description}</p>
</div> </div>
)} )}
@ -340,20 +359,32 @@ export function ImageConfigSidebar({
<Separator /> <Separator />
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</div> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p> <p className="text-sm font-medium">{config.provider}</p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</div> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p> <p className="text-sm font-medium font-mono">{config.model_name}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6"> <div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}> <Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close Close
</Button> </Button>
<Button className="flex-1 gap-2" onClick={handleUseGlobalConfig} disabled={isSubmitting}> <Button
className="flex-1 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"} {isSubmitting ? "Loading..." : "Use This Model"}
</Button> </Button>
</div> </div>
@ -379,7 +410,9 @@ export function ImageConfigSidebar({
<Input <Input
placeholder="Optional description" placeholder="Optional description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))} onChange={(e) =>
setFormData((p) => ({ ...p, description: e.target.value }))
}
/> />
</div> </div>
@ -390,7 +423,9 @@ export function ImageConfigSidebar({
<Label className="text-sm font-medium">Provider *</Label> <Label className="text-sm font-medium">Provider *</Label>
<Select <Select
value={formData.provider} value={formData.provider}
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))} onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a provider" /> <SelectValue placeholder="Select a provider" />
@ -414,7 +449,11 @@ export function ImageConfigSidebar({
{suggestedModels.length > 0 ? ( {suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}> <Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between font-normal"> <Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
{formData.model_name || "Select or type a model..."} {formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@ -424,11 +463,15 @@ export function ImageConfigSidebar({
<CommandInput <CommandInput
placeholder="Search or type model..." placeholder="Search or type model..."
value={formData.model_name} value={formData.model_name}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))} onValueChange={(val) =>
setFormData((p) => ({ ...p, model_name: val }))
}
/> />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
<span className="text-xs text-muted-foreground">Type a custom model name</span> <span className="text-xs text-muted-foreground">
Type a custom model name
</span>
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{suggestedModels.map((m) => ( {suggestedModels.map((m) => (
@ -440,9 +483,18 @@ export function ImageConfigSidebar({
setModelComboboxOpen(false); setModelComboboxOpen(false);
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", formData.model_name === m.value ? "opacity-100" : "opacity-0")} /> <Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === m.value
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-mono text-sm">{m.value}</span> <span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span> <span className="ml-2 text-xs text-muted-foreground">
{m.label}
</span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@ -454,7 +506,9 @@ export function ImageConfigSidebar({
<Input <Input
placeholder="e.g., dall-e-3" placeholder="e.g., dall-e-3"
value={formData.model_name} value={formData.model_name}
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))} onChange={(e) =>
setFormData((p) => ({ ...p, model_name: e.target.value }))
}
/> />
)} )}
</div> </div>
@ -489,14 +543,20 @@ export function ImageConfigSidebar({
<Input <Input
placeholder="2024-02-15-preview" placeholder="2024-02-15-preview"
value={formData.api_version} value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))} onChange={(e) =>
setFormData((p) => ({ ...p, api_version: e.target.value }))
}
/> />
</div> </div>
)} )}
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-4 border-t"> <div className="flex gap-3 pt-4 border-t">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}> <Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button <Button

View file

@ -54,8 +54,7 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const { data: globalConfigs, isLoading: globalLoading } = const { data: globalConfigs, isLoading: globalLoading } = useAtomValue(globalImageGenConfigsAtom);
useAtomValue(globalImageGenConfigsAtom);
const { data: userConfigs, isLoading: userLoading } = useAtomValue(imageGenConfigsAtom); const { data: userConfigs, isLoading: userLoading } = useAtomValue(imageGenConfigsAtom);
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom); const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
@ -309,9 +308,7 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span> <span className="font-medium truncate">{config.name}</span>
{isSelected && ( {isSelected && <Check className="size-3.5 text-primary shrink-0" />}
<Check className="size-3.5 text-primary shrink-0" />
)}
</div> </div>
<span className="text-xs text-muted-foreground truncate block"> <span className="text-xs text-muted-foreground truncate block">
{config.model_name} {config.model_name}

View file

@ -10,6 +10,7 @@ import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useIsMobile } from "@/hooks/use-mobile";
import { fetchThreads } from "@/lib/chat/thread-persistence"; import { fetchThreads } from "@/lib/chat/thread-persistence";
interface TourStep { interface TourStep {
@ -393,6 +394,7 @@ function TourTooltip({
} }
export function OnboardingTour() { export function OnboardingTour() {
const isMobile = useIsMobile();
const [isActive, setIsActive] = useState(false); const [isActive, setIsActive] = useState(false);
const [stepIndex, setStepIndex] = useState(0); const [stepIndex, setStepIndex] = useState(0);
const [targetEl, setTargetEl] = useState<Element | null>(null); const [targetEl, setTargetEl] = useState<Element | null>(null);
@ -685,8 +687,8 @@ export function OnboardingTour() {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isActive, user?.id]); }, [isActive, user?.id]);
// Don't render if not active or not mounted // Don't render on mobile, or if not active or not mounted
if (!mounted || !isActive) { if (isMobile || !mounted || !isActive) {
return null; return null;
} }

View file

@ -184,7 +184,12 @@ export function Pricing({
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
{plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")} {plan.billingText ??
(isNaN(Number(plan.price))
? ""
: isMonthly
? "billed monthly"
: "billed annually")}
</p> </p>
<ul className="mt-5 gap-2 flex flex-col"> <ul className="mt-5 gap-2 flex flex-col">

View file

@ -95,16 +95,29 @@ const item = {
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
// Image gen config atoms // Image gen config atoms
const { mutateAsync: createConfig, isPending: isCreating, error: createError } = const {
useAtomValue(createImageGenConfigMutationAtom); mutateAsync: createConfig,
const { mutateAsync: updateConfig, isPending: isUpdating, error: updateError } = isPending: isCreating,
useAtomValue(updateImageGenConfigMutationAtom); error: createError,
const { mutateAsync: deleteConfig, isPending: isDeleting, error: deleteError } = } = useAtomValue(createImageGenConfigMutationAtom);
useAtomValue(deleteImageGenConfigMutationAtom); const {
mutateAsync: updateConfig,
isPending: isUpdating,
error: updateError,
} = useAtomValue(updateImageGenConfigMutationAtom);
const {
mutateAsync: deleteConfig,
isPending: isDeleting,
error: deleteError,
} = useAtomValue(deleteImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const { data: userConfigs, isFetching: configsLoading, error: fetchError, refetch: refreshConfigs } = const {
useAtomValue(imageGenConfigsAtom); data: userConfigs,
isFetching: configsLoading,
error: fetchError,
refetch: refreshConfigs,
} = useAtomValue(imageGenConfigsAtom);
const { data: globalConfigs = [], isFetching: globalLoading } = const { data: globalConfigs = [], isFetching: globalLoading } =
useAtomValue(globalImageGenConfigsAtom); useAtomValue(globalImageGenConfigsAtom);
const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom); const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom);
@ -249,7 +262,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
data: { data: {
image_generation_config_id: image_generation_config_id:
typeof selectedPrefId === "string" typeof selectedPrefId === "string"
? selectedPrefId ? parseInt(selectedPrefId) : undefined ? selectedPrefId
? parseInt(selectedPrefId)
: undefined
: selectedPrefId, : selectedPrefId,
}, },
}); });
@ -289,7 +304,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Errors */} {/* Errors */}
<AnimatePresence> <AnimatePresence>
{errors.map((err) => ( {errors.map((err) => (
<motion.div key={err?.message} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}> <motion.div
key={err?.message}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Alert variant="destructive" className="py-3"> <Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription> <AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
@ -304,7 +324,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<Sparkles className="h-3 w-3 md:h-4 md:w-4 text-teal-600 dark:text-teal-400 shrink-0" /> <Sparkles className="h-3 w-3 md:h-4 md:w-4 text-teal-600 dark:text-teal-400 shrink-0" />
<AlertDescription className="text-teal-800 dark:text-teal-200 text-xs md:text-sm"> <AlertDescription className="text-teal-800 dark:text-teal-200 text-xs md:text-sm">
<span className="font-medium"> <span className="font-medium">
{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)
</span>{" "} </span>{" "}
available from your administrator. available from your administrator.
</AlertDescription> </AlertDescription>
@ -342,18 +363,27 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</SelectItem> </SelectItem>
{globalConfigs.length > 0 && ( {globalConfigs.length > 0 && (
<> <>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">Global</div> <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Global
</div>
{globalConfigs.map((c) => { {globalConfigs.map((c) => {
const isAuto = "is_auto_mode" in c && c.is_auto_mode; const isAuto = "is_auto_mode" in c && c.is_auto_mode;
return ( return (
<SelectItem key={`g-${c.id}`} value={c.id.toString()}> <SelectItem key={`g-${c.id}`} value={c.id.toString()}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isAuto ? ( {isAuto ? (
<Badge variant="outline" className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200"> <Badge
<Shuffle className="size-3 mr-1" />AUTO variant="outline"
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200"
>
<Shuffle className="size-3 mr-1" />
AUTO
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-xs bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 border-teal-200"> <Badge
variant="outline"
className="text-xs bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 border-teal-200"
>
{c.provider} {c.provider}
</Badge> </Badge>
)} )}
@ -366,11 +396,15 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)} )}
{(userConfigs?.length ?? 0) > 0 && ( {(userConfigs?.length ?? 0) > 0 && (
<> <>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">Your Models</div> <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Your Models
</div>
{userConfigs?.map((c) => ( {userConfigs?.map((c) => (
<SelectItem key={`u-${c.id}`} value={c.id.toString()}> <SelectItem key={`u-${c.id}`} value={c.id.toString()}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">{c.provider}</Badge> <Badge variant="outline" className="text-xs">
{c.provider}
</Badge>
<span>{c.name}</span> <span>{c.name}</span>
<span className="text-muted-foreground">({c.model_name})</span> <span className="text-muted-foreground">({c.model_name})</span>
</div> </div>
@ -382,10 +416,23 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</Select> </Select>
{hasPrefChanges && ( {hasPrefChanges && (
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button size="sm" onClick={handleSavePref} disabled={isSavingPref} className="text-xs h-8"> <Button
size="sm"
onClick={handleSavePref}
disabled={isSavingPref}
className="text-xs h-8"
>
{isSavingPref ? "Saving..." : "Save"} {isSavingPref ? "Saving..." : "Save"}
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => { setSelectedPrefId(preferences.image_generation_config_id ?? ""); setHasPrefChanges(false); }} className="text-xs h-8"> <Button
size="sm"
variant="outline"
onClick={() => {
setSelectedPrefId(preferences.image_generation_config_id ?? "");
setHasPrefChanges(false);
}}
className="text-xs h-8"
>
Reset Reset
</Button> </Button>
</div> </div>
@ -409,7 +456,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Image Models</h3> <h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Image Models</h3>
<Button onClick={openNewDialog} className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"> <Button
onClick={openNewDialog}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
>
<Plus className="h-3 w-3 md:h-4 md:w-4" /> <Plus className="h-3 w-3 md:h-4 md:w-4" />
Add Image Model Add Image Model
</Button> </Button>
@ -435,7 +485,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4"> <motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{userConfigs?.map((config) => ( {userConfigs?.map((config) => (
<motion.div key={config.id} variants={item} layout exit={{ opacity: 0, scale: 0.95 }}> <motion.div
key={config.id}
variants={item}
layout
exit={{ opacity: 0, scale: 0.95 }}
>
<Card className="group overflow-hidden hover:shadow-lg transition-all duration-300 border-muted-foreground/10 hover:border-teal-500/30"> <Card className="group overflow-hidden hover:shadow-lg transition-all duration-300 border-muted-foreground/10 hover:border-teal-500/30">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex"> <div className="flex">
@ -448,8 +503,13 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</div> </div>
<div className="flex-1 min-w-0 space-y-2"> <div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<h4 className="text-sm md:text-base font-semibold truncate">{config.name}</h4> <h4 className="text-sm md:text-base font-semibold truncate">
<Badge variant="secondary" className="text-[9px] md:text-[10px] px-1.5 py-0.5 bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-500/20"> {config.name}
</h4>
<Badge
variant="secondary"
className="text-[9px] md:text-[10px] px-1.5 py-0.5 bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-500/20"
>
{config.provider} {config.provider}
</Badge> </Badge>
</div> </div>
@ -457,7 +517,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{config.model_name} {config.model_name}
</code> </code>
{config.description && ( {config.description && (
<p className="text-[10px] md:text-xs text-muted-foreground line-clamp-1">{config.description}</p> <p className="text-[10px] md:text-xs text-muted-foreground line-clamp-1">
{config.description}
</p>
)} )}
<div className="flex items-center gap-1 text-[10px] md:text-xs text-muted-foreground pt-1"> <div className="flex items-center gap-1 text-[10px] md:text-xs text-muted-foreground pt-1">
<Clock className="h-2.5 w-2.5 md:h-3 md:w-3" /> <Clock className="h-2.5 w-2.5 md:h-3 md:w-3" />
@ -469,7 +531,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={() => openEditDialog(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"> <Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(config)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3.5 w-3.5" /> <Edit3 className="h-3.5 w-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -479,7 +546,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={() => setConfigToDelete(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"> <Button
variant="ghost"
size="sm"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -501,15 +573,30 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)} )}
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={(open) => { if (!open) { setIsDialogOpen(false); setEditingConfig(null); resetForm(); } }}> <Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
}
}}
>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{editingConfig ? <Edit3 className="w-5 h-5 text-teal-600" /> : <Plus className="w-5 h-5 text-teal-600" />} {editingConfig ? (
<Edit3 className="w-5 h-5 text-teal-600" />
) : (
<Plus className="w-5 h-5 text-teal-600" />
)}
{editingConfig ? "Edit Image Model" : "Add Image Model"} {editingConfig ? "Edit Image Model" : "Add Image Model"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{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.)"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -541,7 +628,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<Label className="text-sm font-medium">Provider *</Label> <Label className="text-sm font-medium">Provider *</Label>
<Select <Select
value={formData.provider} value={formData.provider}
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))} onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a provider" /> <SelectValue placeholder="Select a provider" />
@ -565,7 +654,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{suggestedModels.length > 0 ? ( {suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}> <Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between font-normal"> <Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
{formData.model_name || "Select or type a model..."} {formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@ -579,7 +672,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
/> />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
<span className="text-xs text-muted-foreground">Type a custom model name</span> <span className="text-xs text-muted-foreground">
Type a custom model name
</span>
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{suggestedModels.map((m) => ( {suggestedModels.map((m) => (
@ -591,7 +686,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
setModelComboboxOpen(false); setModelComboboxOpen(false);
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", formData.model_name === m.value ? "opacity-100" : "opacity-0")} /> <Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === m.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-mono text-sm">{m.value}</span> <span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span> <span className="ml-2 text-xs text-muted-foreground">{m.label}</span>
</CommandItem> </CommandItem>
@ -650,14 +750,24 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<Button <Button
variant="outline" variant="outline"
className="flex-1" className="flex-1"
onClick={() => { setIsDialogOpen(false); setEditingConfig(null); resetForm(); }} onClick={() => {
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
}}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
className="flex-1" className="flex-1"
onClick={handleFormSubmit} onClick={handleFormSubmit}
disabled={isSubmitting || !formData.name || !formData.provider || !formData.model_name || !formData.api_key} disabled={
isSubmitting ||
!formData.name ||
!formData.provider ||
!formData.model_name ||
!formData.api_key
}
> >
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null} {isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
{editingConfig ? "Save Changes" : "Create & Use"} {editingConfig ? "Save Changes" : "Create & Use"}
@ -668,7 +778,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</Dialog> </Dialog>
{/* Delete Confirmation */} {/* Delete Confirmation */}
<AlertDialog open={!!configToDelete} onOpenChange={(open) => !open && setConfigToDelete(null)}> <AlertDialog
open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"> <AlertDialogTitle className="flex items-center gap-2">
@ -676,13 +789,28 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
Delete Image Model Delete Image Model
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete <span className="font-semibold text-foreground">{configToDelete?.name}</span>? Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> <AlertDialogAction
{isDeleting ? <><Spinner size="sm" className="mr-2" />Deleting</> : <><Trash2 className="mr-2 h-4 w-4" />Delete</>} onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Spinner size="sm" className="mr-2" />
Deleting
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</>
)}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -345,9 +345,7 @@ export function Image({
variant="secondary" variant="secondary"
className={cn( className={cn(
"border-0 text-xs backdrop-blur-sm", "border-0 text-xs backdrop-blur-sm",
isGenerated isGenerated ? "bg-primary/80 text-primary-foreground" : "bg-black/60 text-white"
? "bg-primary/80 text-primary-foreground"
: "bg-black/60 text-white"
)} )}
> >
{isGenerated && <SparklesIcon className="size-3 mr-1" />} {isGenerated && <SparklesIcon className="size-3 mr-1" />}

View file

@ -205,6 +205,7 @@ export const getNotificationsRequest = z.object({
search_space_id: z.number().optional(), search_space_id: z.number().optional(),
type: inboxItemTypeEnum.optional(), type: inboxItemTypeEnum.optional(),
before_date: z.string().optional(), before_date: z.string().optional(),
search: z.string().optional(),
limit: z.number().min(1).max(100).optional(), limit: z.number().min(1).max(100).optional(),
offset: z.number().min(0).optional(), offset: z.number().min(0).optional(),
}), }),

View file

@ -213,9 +213,7 @@ export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
export const updateImageGenConfigRequest = z.object({ export const updateImageGenConfigRequest = z.object({
id: z.number(), id: z.number(),
data: imageGenerationConfig data: imageGenerationConfig.omit({ id: true, created_at: true, search_space_id: true }).partial(),
.omit({ id: true, created_at: true, search_space_id: true })
.partial(),
}); });
export const updateImageGenConfigResponse = imageGenerationConfig; export const updateImageGenConfigResponse = imageGenerationConfig;

View file

@ -71,8 +71,14 @@ function isValidDocument(doc: DocumentElectric): boolean {
* 3. Use syncHandle.isUpToDate to determine if deletions can be trusted * 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
* 4. Handles bulk deletions correctly by checking sync state * 4. Handles bulk deletions correctly by checking sync state
* *
* Filtering strategy:
* - Internal state always stores ALL documents (unfiltered)
* - typeFilter is applied client-side when returning documents
* - typeCounts always reflect the full dataset so the filter sidebar stays complete
* - Changing filters is instant (no API re-fetch or Electric re-sync)
*
* @param searchSpaceId - The search space ID to filter documents * @param searchSpaceId - The search space ID to filter documents
* @param typeFilter - Optional document types to filter by * @param typeFilter - Optional document types to filter by (applied client-side)
*/ */
export function useDocuments( export function useDocuments(
searchSpaceId: number | null, searchSpaceId: number | null,
@ -80,7 +86,8 @@ export function useDocuments(
) { ) {
const electricClient = useElectricClient(); const electricClient = useElectricClient();
const [documents, setDocuments] = useState<DocumentDisplay[]>([]); // Internal state: ALL documents (unfiltered)
const [allDocuments, setAllDocuments] = useState<DocumentDisplay[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@ -94,14 +101,21 @@ export function useDocuments(
const syncHandleRef = useRef<SyncHandle | null>(null); const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
// Real-time type counts // Type counts from ALL documents (unfiltered) — keeps filter sidebar complete
const typeCounts = useMemo(() => { const typeCounts = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const doc of documents) { for (const doc of allDocuments) {
counts[doc.document_type] = (counts[doc.document_type] || 0) + 1; counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
} }
return counts; return counts;
}, [documents]); }, [allDocuments]);
// Client-side filtered documents for display
const documents = useMemo(() => {
if (typeFilter.length === 0) return allDocuments;
const filterSet = new Set<string>(typeFilter);
return allDocuments.filter((doc) => filterSet.has(doc.document_type));
}, [allDocuments, typeFilter]);
// Populate user cache from API response // Populate user cache from API response
const populateUserCache = useCallback( const populateUserCache = useCallback(
@ -151,7 +165,8 @@ export function useDocuments(
[] []
); );
// EFFECT 1: Load from API (PRIMARY source of truth) // EFFECT 1: Load ALL documents from API (PRIMARY source of truth)
// No type filter — always fetches everything so typeCounts stay complete
useEffect(() => { useEffect(() => {
if (!searchSpaceId) { if (!searchSpaceId) {
setLoading(false); setLoading(false);
@ -160,7 +175,6 @@ export function useDocuments(
// Capture validated value for async closure // Capture validated value for async closure
const spaceId = searchSpaceId; const spaceId = searchSpaceId;
const currentTypeFilter = typeFilter;
let mounted = true; let mounted = true;
apiLoadedRef.current = false; apiLoadedRef.current = false;
@ -174,8 +188,7 @@ export function useDocuments(
queryParams: { queryParams: {
search_space_id: spaceId, search_space_id: spaceId,
page: 0, page: 0,
page_size: -1, // Fetch all documents page_size: -1, // Fetch all documents (unfiltered)
...(currentTypeFilter.length > 0 && { document_types: currentTypeFilter }),
}, },
}); });
@ -183,7 +196,7 @@ export function useDocuments(
populateUserCache(response.items); populateUserCache(response.items);
const docs = response.items.map(apiToDisplayDoc); const docs = response.items.map(apiToDisplayDoc);
setDocuments(docs); setAllDocuments(docs);
apiLoadedRef.current = true; apiLoadedRef.current = true;
setError(null); setError(null);
console.log("[useDocuments] API loaded", docs.length, "documents"); console.log("[useDocuments] API loaded", docs.length, "documents");
@ -201,16 +214,16 @@ export function useDocuments(
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [searchSpaceId, typeFilter, populateUserCache, apiToDisplayDoc]); }, [searchSpaceId, populateUserCache, apiToDisplayDoc]);
// EFFECT 2: Start Electric sync + live query for real-time updates // EFFECT 2: Start Electric sync + live query for real-time updates
// No type filter — syncs and queries ALL documents; filtering is client-side
useEffect(() => { useEffect(() => {
if (!searchSpaceId || !electricClient) return; if (!searchSpaceId || !electricClient) return;
// Capture validated values for async closure // Capture validated values for async closure
const spaceId = searchSpaceId; const spaceId = searchSpaceId;
const client = electricClient; const client = electricClient;
const currentTypeFilter = typeFilter;
let mounted = true; let mounted = true;
@ -228,7 +241,7 @@ export function useDocuments(
try { try {
console.log("[useDocuments] Starting Electric sync for real-time updates"); console.log("[useDocuments] Starting Electric sync for real-time updates");
// Start Electric sync // Start Electric sync (all documents for this search space)
const handle = await client.syncShape({ const handle = await client.syncShape({
table: "documents", table: "documents",
where: `search_space_id = ${spaceId}`, where: `search_space_id = ${spaceId}`,
@ -263,7 +276,7 @@ export function useDocuments(
if (!mounted) return; if (!mounted) return;
// Set up live query // Set up live query (unfiltered — type filtering is done client-side)
const db = client.db as { const db = client.db as {
live?: { live?: {
query: <T>( query: <T>(
@ -281,21 +294,12 @@ export function useDocuments(
return; return;
} }
let query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status const query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status
FROM documents FROM documents
WHERE search_space_id = $1`; WHERE search_space_id = $1
ORDER BY created_at DESC`;
const params: (number | string)[] = [spaceId]; const liveQuery = await db.live.query<DocumentElectric>(query, [spaceId]);
if (currentTypeFilter.length > 0) {
const placeholders = currentTypeFilter.map((_, i) => `$${i + 2}`).join(", ");
query += ` AND document_type IN (${placeholders})`;
params.push(...currentTypeFilter);
}
query += ` ORDER BY created_at DESC`;
const liveQuery = await db.live.query<DocumentElectric>(query, params);
if (!mounted) { if (!mounted) {
liveQuery.unsubscribe?.(); liveQuery.unsubscribe?.();
@ -333,7 +337,7 @@ export function useDocuments(
.then((response) => { .then((response) => {
populateUserCache(response.items); populateUserCache(response.items);
if (mounted) { if (mounted) {
setDocuments((prev) => setAllDocuments((prev) =>
prev.map((doc) => ({ prev.map((doc) => ({
...doc, ...doc,
created_by_name: doc.created_by_id created_by_name: doc.created_by_id
@ -347,7 +351,7 @@ export function useDocuments(
} }
// Smart update logic based on sync state // Smart update logic based on sync state
setDocuments((prev) => { setAllDocuments((prev) => {
// Don't process if API hasn't loaded yet // Don't process if API hasn't loaded yet
if (!apiLoadedRef.current) { if (!apiLoadedRef.current) {
console.log("[useDocuments] Waiting for API load, skipping live update"); console.log("[useDocuments] Waiting for API load, skipping live update");
@ -424,7 +428,7 @@ export function useDocuments(
liveQueryRef.current = null; liveQueryRef.current = null;
} }
}; };
}, [searchSpaceId, electricClient, typeFilter, electricToDisplayDoc, populateUserCache]); }, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]);
// Track previous searchSpaceId to detect actual changes // Track previous searchSpaceId to detect actual changes
const prevSearchSpaceIdRef = useRef<number | null>(null); const prevSearchSpaceIdRef = useRef<number | null>(null);
@ -432,7 +436,7 @@ export function useDocuments(
// Reset on search space change (not on initial mount) // Reset on search space change (not on initial mount)
useEffect(() => { useEffect(() => {
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) { if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
setDocuments([]); setAllDocuments([]);
apiLoadedRef.current = false; apiLoadedRef.current = false;
userCacheRef.current.clear(); userCacheRef.current.clear();
} }

View file

@ -32,11 +32,9 @@ class ImageGenConfigApiService {
const msg = parsed.error.issues.map((i) => i.message).join(", "); const msg = parsed.error.issues.map((i) => i.message).join(", ");
throw new ValidationError(`Invalid request: ${msg}`); throw new ValidationError(`Invalid request: ${msg}`);
} }
return baseApiService.post( return baseApiService.post(`/api/v1/image-generation-configs`, createImageGenConfigResponse, {
`/api/v1/image-generation-configs`, body: parsed.data,
createImageGenConfigResponse, });
{ body: parsed.data }
);
}; };
/** /**

View file

@ -51,6 +51,9 @@ class NotificationsApiService {
if (queryParams.offset !== undefined) { if (queryParams.offset !== undefined) {
params.append("offset", String(queryParams.offset)); params.append("offset", String(queryParams.offset));
} }
if (queryParams.search) {
params.append("search", queryParams.search);
}
const queryString = params.toString(); const queryString = params.toString();

View file

@ -67,14 +67,10 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
// v2: user-specific database architecture // v2: user-specific database architecture
// v3: consistent cutoff date for sync+queries, visibility refresh support // v3: consistent cutoff date for sync+queries, visibility refresh support
// v4: heartbeat-based stale notification detection with updated_at tracking // v4: heartbeat-based stale notification detection with updated_at tracking
// v5: fixed duplicate key errors (root cause: unstable cutoff dates in use-inbox.ts) // v5: fixed duplicate key errors, stable cutoff dates, onMustRefetch handler,
// - added onMustRefetch handler for server-side refetch scenarios // real-time documents table with title/created_by_id/status columns,
// - fixed getSyncCutoffDate to use stable midnight UTC timestamps // consolidated single documents sync, pending state for document queue visibility
// v6: real-time documents table - added title and created_by_id columns for live document display const SYNC_VERSION = 5;
// v7: removed use-documents-electric.ts - consolidated to single documents sync to prevent conflicts
// v8: added status column for real-time document processing status (ready/processing/failed)
// v9: added pending state for accurate document queue visibility
const SYNC_VERSION = 11;
// Database name prefix for identifying SurfSense databases // Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-"; const DB_PREFIX = "surfsense-";

View file

@ -92,4 +92,8 @@ export const cacheKeys = {
bySearchSpace: (searchSpaceId: number) => bySearchSpace: (searchSpaceId: number) =>
["public-chat-snapshots", "search-space", searchSpaceId] as const, ["public-chat-snapshots", "search-space", searchSpaceId] as const,
}, },
notifications: {
search: (searchSpaceId: number | null, search: string, tab: string) =>
["notifications", "search", searchSpaceId, search, tab] as const,
},
}; };

View file

@ -376,7 +376,7 @@
"upload_documents": { "upload_documents": {
"title": "Upload Documents", "title": "Upload Documents",
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.", "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.", "upload_limits": "Upload limit: {maxFiles} files, {maxSizeMB}MB total.",
"drop_files": "Drop files here", "drop_files": "Drop files here",
"drag_drop": "Drag & drop files here", "drag_drop": "Drag & drop files here",

View file

@ -360,7 +360,7 @@
"upload_documents": { "upload_documents": {
"title": "上传文档", "title": "上传文档",
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。", "subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
"file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。", "file_size_limit": "最大文件大小:每个文件 50MB。",
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。", "upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
"drop_files": "放下文件到这里", "drop_files": "放下文件到这里",
"drag_drop": "拖放文件到这里", "drag_drop": "拖放文件到这里",