mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
merge
This commit is contained in:
commit
49d51ba569
70 changed files with 4266 additions and 1362 deletions
|
|
@ -1,81 +0,0 @@
|
|||
"""Add COMPOSIO_CONNECTOR to SearchSourceConnectorType and DocumentType enums
|
||||
|
||||
Revision ID: 74
|
||||
Revises: 73
|
||||
Create Date: 2026-01-21
|
||||
|
||||
This migration adds the COMPOSIO_CONNECTOR enum value to both:
|
||||
- searchsourceconnectortype (for connector type tracking)
|
||||
- documenttype (for document type tracking)
|
||||
|
||||
Composio is a managed OAuth integration service that allows connecting
|
||||
to various third-party services (Google Drive, Gmail, Calendar, etc.)
|
||||
without requiring separate OAuth app verification.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "74"
|
||||
down_revision: str | None = "73"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# Define the ENUM type names and the new value
|
||||
CONNECTOR_ENUM = "searchsourceconnectortype"
|
||||
CONNECTOR_NEW_VALUE = "COMPOSIO_CONNECTOR"
|
||||
DOCUMENT_ENUM = "documenttype"
|
||||
DOCUMENT_NEW_VALUE = "COMPOSIO_CONNECTOR"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema - add COMPOSIO_CONNECTOR to connector and document enums safely."""
|
||||
# Add COMPOSIO_CONNECTOR to searchsourceconnectortype only if not exists
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = '{CONNECTOR_NEW_VALUE}'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{CONNECTOR_ENUM}')
|
||||
) THEN
|
||||
ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}';
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add COMPOSIO_CONNECTOR to documenttype only if not exists
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = '{DOCUMENT_NEW_VALUE}'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{DOCUMENT_ENUM}')
|
||||
) THEN
|
||||
ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}';
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema - remove COMPOSIO_CONNECTOR from connector and document enums.
|
||||
|
||||
Note: PostgreSQL does not support removing enum values directly.
|
||||
To properly downgrade, you would need to:
|
||||
1. Delete any rows using the COMPOSIO_CONNECTOR value
|
||||
2. Create new enums without COMPOSIO_CONNECTOR
|
||||
3. Alter the columns to use the new enums
|
||||
4. Drop the old enums
|
||||
|
||||
This is left as a no-op since removing enum values is complex
|
||||
and typically not needed in practice.
|
||||
"""
|
||||
pass
|
||||
29
surfsense_backend/alembic/versions/74_no_op.py
Normal file
29
surfsense_backend/alembic/versions/74_no_op.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""No-op migration for Composio support
|
||||
|
||||
Revision ID: 74
|
||||
Revises: 73
|
||||
Create Date: 2026-01-21
|
||||
|
||||
NOTE: This migration is a no-op since Composio is not supported yet.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "74"
|
||||
down_revision: str | None = "73"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""No-op upgrade for Composio support."""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""No-op downgrade for Composio support.
|
||||
|
||||
Note: PostgreSQL does not support removing enum values directly.
|
||||
"""
|
||||
pass
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"""Add chat_session_state table for live collaboration
|
||||
|
||||
Revision ID: 75
|
||||
Revises: 74
|
||||
|
||||
Creates chat_session_state table to track AI responding state per thread.
|
||||
Enables real-time sync via Electric SQL for shared chat collaboration.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "75"
|
||||
down_revision: str | None = "74"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create chat_session_state table with Electric SQL replication."""
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_session_state (
|
||||
id SERIAL PRIMARY KEY,
|
||||
thread_id INTEGER NOT NULL REFERENCES new_chat_threads(id) ON DELETE CASCADE,
|
||||
ai_responding_to_user_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (thread_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_session_state_thread_id ON chat_session_state(thread_id)"
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE chat_session_state REPLICA IDENTITY FULL;")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'chat_session_state'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default ADD TABLE chat_session_state;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop chat_session_state table and remove from Electric SQL replication."""
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'chat_session_state'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default DROP TABLE chat_session_state;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute("DROP TABLE IF EXISTS chat_session_state;")
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"""Add Obsidian connector enums
|
||||
|
||||
Revision ID: 75
|
||||
Revises: 74
|
||||
Create Date: 2026-01-21
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "75"
|
||||
down_revision: str | None = "74"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add OBSIDIAN_CONNECTOR to documenttype enum
|
||||
op.execute("ALTER TYPE documenttype ADD VALUE IF NOT EXISTS 'OBSIDIAN_CONNECTOR'")
|
||||
|
||||
# Add OBSIDIAN_CONNECTOR to searchsourceconnectortype enum
|
||||
op.execute(
|
||||
"ALTER TYPE searchsourceconnectortype ADD VALUE IF NOT EXISTS 'OBSIDIAN_CONNECTOR'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Note: PostgreSQL doesn't support removing enum values directly.
|
||||
# The values will remain in the enum type but won't be used.
|
||||
pass
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
"""Add live collaboration tables to Electric SQL publication
|
||||
|
||||
Revision ID: 76
|
||||
Revises: 75
|
||||
|
||||
Enables real-time sync for live collaboration features:
|
||||
- new_chat_messages: Live message sync between users
|
||||
- chat_comments: Live comment updates
|
||||
|
||||
Note: User/member info is fetched via API (membersAtom) for client-side joins,
|
||||
not via Electric SQL, to keep where clauses optimized and reduce complexity.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "76"
|
||||
down_revision: str | None = "75"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add live collaboration tables to Electric SQL replication."""
|
||||
# Set REPLICA IDENTITY FULL for Electric SQL sync
|
||||
op.execute("ALTER TABLE new_chat_messages REPLICA IDENTITY FULL;")
|
||||
op.execute("ALTER TABLE chat_comments REPLICA IDENTITY FULL;")
|
||||
|
||||
# Add new_chat_messages to Electric publication
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'new_chat_messages'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default ADD TABLE new_chat_messages;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add chat_comments to Electric publication
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'chat_comments'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default ADD TABLE chat_comments;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove live collaboration tables from Electric SQL replication."""
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'new_chat_messages'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default DROP TABLE new_chat_messages;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'chat_comments'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default DROP TABLE chat_comments;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Note: Not reverting REPLICA IDENTITY as it doesn't harm normal operations
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""Add thread_id to chat_comments for denormalized Electric subscriptions
|
||||
|
||||
This denormalization allows a single Electric SQL subscription per thread
|
||||
instead of one per message, significantly reducing connection overhead.
|
||||
|
||||
Revision ID: 77
|
||||
Revises: 76
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "77"
|
||||
down_revision: str | None = "76"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add thread_id column to chat_comments and backfill from messages."""
|
||||
# Add the column (nullable initially for backfill)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE chat_comments
|
||||
ADD COLUMN IF NOT EXISTS thread_id INTEGER;
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill thread_id from the related message
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE chat_comments c
|
||||
SET thread_id = m.thread_id
|
||||
FROM new_chat_messages m
|
||||
WHERE c.message_id = m.id
|
||||
AND c.thread_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# Make it NOT NULL after backfill
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE chat_comments
|
||||
ALTER COLUMN thread_id SET NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add FK constraint
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE chat_comments
|
||||
ADD CONSTRAINT fk_chat_comments_thread_id
|
||||
FOREIGN KEY (thread_id) REFERENCES new_chat_threads(id) ON DELETE CASCADE;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add index for efficient Electric subscriptions by thread
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove thread_id column from chat_comments."""
|
||||
op.execute("DROP INDEX IF EXISTS idx_chat_comments_thread_id")
|
||||
op.execute(
|
||||
"ALTER TABLE chat_comments DROP CONSTRAINT IF EXISTS fk_chat_comments_thread_id"
|
||||
)
|
||||
op.execute("ALTER TABLE chat_comments DROP COLUMN IF EXISTS thread_id")
|
||||
|
|
@ -268,7 +268,9 @@ class ComposioConnector:
|
|||
from_email = header_dict.get("from", "Unknown Sender")
|
||||
to_email = header_dict.get("to", "Unknown Recipient")
|
||||
# Composio provides messageTimestamp directly
|
||||
date_str = message.get("messageTimestamp", "") or header_dict.get("date", "Unknown Date")
|
||||
date_str = message.get("messageTimestamp", "") or header_dict.get(
|
||||
"date", "Unknown Date"
|
||||
)
|
||||
|
||||
# Build markdown content
|
||||
markdown_content = f"# {subject}\n\n"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ class GitHubConnector:
|
|||
if self.token:
|
||||
logger.info("GitHub connector initialized with authentication token.")
|
||||
else:
|
||||
logger.info("GitHub connector initialized without token (public repos only).")
|
||||
logger.info(
|
||||
"GitHub connector initialized without token (public repos only)."
|
||||
)
|
||||
|
||||
def ingest_repository(
|
||||
self,
|
||||
|
|
@ -95,17 +97,27 @@ class GitHubConnector:
|
|||
cmd = [
|
||||
"gitingest",
|
||||
repo_url,
|
||||
"--output", output_path,
|
||||
"--max-size", str(max_file_size),
|
||||
"--output",
|
||||
output_path,
|
||||
"--max-size",
|
||||
str(max_file_size),
|
||||
# Common exclude patterns
|
||||
"-e", "node_modules/*",
|
||||
"-e", "vendor/*",
|
||||
"-e", ".git/*",
|
||||
"-e", "__pycache__/*",
|
||||
"-e", "dist/*",
|
||||
"-e", "build/*",
|
||||
"-e", "*.lock",
|
||||
"-e", "package-lock.json",
|
||||
"-e",
|
||||
"node_modules/*",
|
||||
"-e",
|
||||
"vendor/*",
|
||||
"-e",
|
||||
".git/*",
|
||||
"-e",
|
||||
"__pycache__/*",
|
||||
"-e",
|
||||
"dist/*",
|
||||
"-e",
|
||||
"build/*",
|
||||
"-e",
|
||||
"*.lock",
|
||||
"-e",
|
||||
"package-lock.json",
|
||||
]
|
||||
|
||||
# Add branch if specified
|
||||
|
|
@ -147,7 +159,9 @@ class GitHubConnector:
|
|||
os.unlink(output_path)
|
||||
|
||||
if not full_content or not full_content.strip():
|
||||
logger.warning(f"No content retrieved from repository: {repo_full_name}")
|
||||
logger.warning(
|
||||
f"No content retrieved from repository: {repo_full_name}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse the gitingest output
|
||||
|
|
@ -171,11 +185,11 @@ class GitHubConnector:
|
|||
logger.error(f"gitingest timed out for repository: {repo_full_name}")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"gitingest CLI not found. Falling back to Python library."
|
||||
)
|
||||
logger.error("gitingest CLI not found. Falling back to Python library.")
|
||||
# Fall back to Python library
|
||||
return self._ingest_with_python_library(repo_full_name, branch, max_file_size)
|
||||
return self._ingest_with_python_library(
|
||||
repo_full_name, branch, max_file_size
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest repository {repo_full_name}: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ class SearchSourceConnectorType(str, Enum):
|
|||
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR"
|
||||
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR" # Self-hosted only - Local Obsidian vault indexing
|
||||
MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools
|
||||
COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.)
|
||||
COMPOSIO_CONNECTOR = (
|
||||
"COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.)
|
||||
)
|
||||
|
||||
|
||||
class LiteLLMProvider(str, Enum):
|
||||
|
|
@ -417,6 +419,13 @@ class ChatComment(BaseModel, TimestampMixin):
|
|||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Denormalized thread_id for efficient Electric SQL subscriptions (one per thread)
|
||||
thread_id = Column(
|
||||
Integer,
|
||||
ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
parent_id = Column(
|
||||
Integer,
|
||||
ForeignKey("chat_comments.id", ondelete="CASCADE"),
|
||||
|
|
@ -440,6 +449,7 @@ class ChatComment(BaseModel, TimestampMixin):
|
|||
|
||||
# Relationships
|
||||
message = relationship("NewChatMessage", back_populates="comments")
|
||||
thread = relationship("NewChatThread")
|
||||
author = relationship("User")
|
||||
parent = relationship(
|
||||
"ChatComment", remote_side="ChatComment.id", backref="replies"
|
||||
|
|
@ -476,6 +486,38 @@ class ChatCommentMention(BaseModel, TimestampMixin):
|
|||
mentioned_user = relationship("User")
|
||||
|
||||
|
||||
class ChatSessionState(BaseModel):
|
||||
"""
|
||||
Tracks real-time session state for shared chat collaboration.
|
||||
One record per thread, synced via Electric SQL.
|
||||
"""
|
||||
|
||||
__tablename__ = "chat_session_state"
|
||||
|
||||
thread_id = Column(
|
||||
Integer,
|
||||
ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
ai_responding_to_user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("user.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
updated_at = Column(
|
||||
TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
thread = relationship("NewChatThread")
|
||||
ai_responding_to_user = relationship("User")
|
||||
|
||||
|
||||
class MemoryCategory(str, Enum):
|
||||
"""Categories for user memories."""
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ async def list_composio_toolkits(user: User = Depends(current_active_user)):
|
|||
@router.get("/auth/composio/connector/add")
|
||||
async def initiate_composio_auth(
|
||||
space_id: int,
|
||||
toolkit_id: str = Query(..., description="Composio toolkit ID (e.g., 'googledrive', 'gmail')"),
|
||||
toolkit_id: str = Query(
|
||||
..., description="Composio toolkit ID (e.g., 'googledrive', 'gmail')"
|
||||
),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
|
|
@ -165,7 +167,9 @@ async def initiate_composio_auth(
|
|||
@router.get("/auth/composio/connector/callback")
|
||||
async def composio_callback(
|
||||
state: str | None = None,
|
||||
connectedAccountId: str | None = None, # Composio sends camelCase
|
||||
composio_connected_account_id: str | None = Query(
|
||||
None, alias="connectedAccountId"
|
||||
), # Composio sends camelCase
|
||||
connected_account_id: str | None = None, # Fallback snake_case
|
||||
error: str | None = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
|
|
@ -232,15 +236,18 @@ async def composio_callback(
|
|||
)
|
||||
|
||||
# Initialize Composio service
|
||||
service = ComposioService()
|
||||
entity_id = f"surfsense_{user_id}"
|
||||
|
||||
ComposioService()
|
||||
|
||||
# Use camelCase param if provided (Composio's format), fallback to snake_case
|
||||
final_connected_account_id = connectedAccountId or connected_account_id
|
||||
|
||||
final_connected_account_id = (
|
||||
composio_connected_account_id or connected_account_id
|
||||
)
|
||||
|
||||
# DEBUG: Log all query parameters received
|
||||
logger.info(f"DEBUG: Callback received - connectedAccountId: {connectedAccountId}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}")
|
||||
|
||||
logger.info(
|
||||
f"DEBUG: Callback received - connectedAccountId: {composio_connected_account_id}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}"
|
||||
)
|
||||
|
||||
# If we still don't have a connected_account_id, warn but continue
|
||||
# (the connector will be created but indexing won't work until updated)
|
||||
if not final_connected_account_id:
|
||||
|
|
@ -249,7 +256,9 @@ async def composio_callback(
|
|||
"The connector will be created but indexing may not work."
|
||||
)
|
||||
else:
|
||||
logger.info(f"Successfully got connected_account_id: {final_connected_account_id}")
|
||||
logger.info(
|
||||
f"Successfully got connected_account_id: {final_connected_account_id}"
|
||||
)
|
||||
|
||||
# Build connector config
|
||||
connector_config = {
|
||||
|
|
|
|||
|
|
@ -990,7 +990,7 @@ async def handle_new_chat(
|
|||
search_space_id=request.search_space_id,
|
||||
chat_id=request.chat_id,
|
||||
session=session,
|
||||
user_id=str(user.id), # Pass user ID for memory tools
|
||||
user_id=str(user.id), # Pass user ID for memory tools and session state
|
||||
llm_config_id=llm_config_id,
|
||||
attachments=request.attachments,
|
||||
mentioned_document_ids=request.mentioned_document_ids,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"""
|
||||
Notifications API routes.
|
||||
These endpoints allow marking notifications as read.
|
||||
Electric SQL automatically syncs the changes to all connected clients.
|
||||
These endpoints allow marking notifications as read and fetching older notifications.
|
||||
Electric SQL automatically syncs the changes to all connected clients for recent items.
|
||||
For older items (beyond the sync window), use the list endpoint.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import desc, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import Notification, User, get_async_session
|
||||
|
|
@ -14,6 +17,36 @@ from app.users import current_active_user
|
|||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
|
||||
SYNC_WINDOW_DAYS = 14
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
"""Response model for a single notification."""
|
||||
|
||||
id: int
|
||||
user_id: str
|
||||
search_space_id: int | None
|
||||
type: str
|
||||
title: str
|
||||
message: str
|
||||
read: bool
|
||||
metadata: dict
|
||||
created_at: str
|
||||
updated_at: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
"""Response for listing notifications with pagination."""
|
||||
|
||||
items: list[NotificationResponse]
|
||||
total: int
|
||||
has_more: bool
|
||||
next_offset: int | None
|
||||
|
||||
|
||||
class MarkReadResponse(BaseModel):
|
||||
"""Response for mark as read operations."""
|
||||
|
|
@ -30,6 +63,169 @@ class MarkAllReadResponse(BaseModel):
|
|||
updated_count: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
"""Response for unread count with split between recent and older items."""
|
||||
|
||||
total_unread: int
|
||||
recent_unread: int # Within SYNC_WINDOW_DAYS
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def get_unread_count(
|
||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UnreadCountResponse:
|
||||
"""
|
||||
Get the total unread notification count for the current user.
|
||||
|
||||
Returns both:
|
||||
- total_unread: All unread notifications (for accurate badge count)
|
||||
- recent_unread: Unread notifications within the sync window (last 14 days)
|
||||
|
||||
This allows the frontend to calculate:
|
||||
- older_unread = total_unread - recent_unread (static until reconciliation)
|
||||
- Display count = older_unread + live_recent_count (from Electric SQL)
|
||||
"""
|
||||
# Calculate cutoff date for sync window
|
||||
cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS)
|
||||
|
||||
# Base filter for user's unread notifications
|
||||
base_filter = [
|
||||
Notification.user_id == user.id,
|
||||
Notification.read == False, # noqa: E712
|
||||
]
|
||||
|
||||
# Add search space filter if provided (include null for global notifications)
|
||||
if search_space_id is not None:
|
||||
base_filter.append(
|
||||
(Notification.search_space_id == search_space_id)
|
||||
| (Notification.search_space_id.is_(None))
|
||||
)
|
||||
|
||||
# Total unread count (all time)
|
||||
total_query = select(func.count(Notification.id)).where(*base_filter)
|
||||
total_result = await session.execute(total_query)
|
||||
total_unread = total_result.scalar() or 0
|
||||
|
||||
# Recent unread count (within sync window)
|
||||
recent_query = select(func.count(Notification.id)).where(
|
||||
*base_filter,
|
||||
Notification.created_at > cutoff_date,
|
||||
)
|
||||
recent_result = await session.execute(recent_query)
|
||||
recent_unread = recent_result.scalar() or 0
|
||||
|
||||
return UnreadCountResponse(
|
||||
total_unread=total_unread,
|
||||
recent_unread=recent_unread,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||
type_filter: str | None = Query(
|
||||
None, alias="type", description="Filter by notification type"
|
||||
),
|
||||
before_date: str | None = Query(
|
||||
None, description="Get notifications before this ISO date (for pagination)"
|
||||
),
|
||||
limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of items to skip"),
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> NotificationListResponse:
|
||||
"""
|
||||
List notifications for the current user with pagination.
|
||||
|
||||
This endpoint is used as a fallback for older notifications that are
|
||||
outside the Electric SQL sync window (2 weeks).
|
||||
|
||||
Use `before_date` to paginate through older notifications efficiently.
|
||||
"""
|
||||
# Build base query
|
||||
query = select(Notification).where(Notification.user_id == user.id)
|
||||
count_query = select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user.id
|
||||
)
|
||||
|
||||
# Filter by search space (include null search_space_id for global notifications)
|
||||
if search_space_id is not None:
|
||||
query = query.where(
|
||||
(Notification.search_space_id == search_space_id)
|
||||
| (Notification.search_space_id.is_(None))
|
||||
)
|
||||
count_query = count_query.where(
|
||||
(Notification.search_space_id == search_space_id)
|
||||
| (Notification.search_space_id.is_(None))
|
||||
)
|
||||
|
||||
# Filter by type
|
||||
if type_filter:
|
||||
query = query.where(Notification.type == type_filter)
|
||||
count_query = count_query.where(Notification.type == type_filter)
|
||||
|
||||
# Filter by date (for efficient pagination of older items)
|
||||
if before_date:
|
||||
try:
|
||||
before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00"))
|
||||
query = query.where(Notification.created_at < before_datetime)
|
||||
count_query = count_query.where(Notification.created_at < before_datetime)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
|
||||
) from None
|
||||
|
||||
# Get total count
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply ordering and pagination
|
||||
query = (
|
||||
query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1)
|
||||
)
|
||||
|
||||
# Execute query
|
||||
result = await session.execute(query)
|
||||
notifications = result.scalars().all()
|
||||
|
||||
# Check if there are more items
|
||||
has_more = len(notifications) > limit
|
||||
if has_more:
|
||||
notifications = notifications[:limit]
|
||||
|
||||
# Convert to response format
|
||||
items = []
|
||||
for notification in notifications:
|
||||
items.append(
|
||||
NotificationResponse(
|
||||
id=notification.id,
|
||||
user_id=str(notification.user_id),
|
||||
search_space_id=notification.search_space_id,
|
||||
type=notification.type,
|
||||
title=notification.title,
|
||||
message=notification.message,
|
||||
read=notification.read,
|
||||
metadata=notification.notification_metadata or {},
|
||||
created_at=notification.created_at.isoformat()
|
||||
if notification.created_at
|
||||
else "",
|
||||
updated_at=notification.updated_at.isoformat()
|
||||
if notification.updated_at
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
return NotificationListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
next_offset=offset + limit if has_more else None,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
|
||||
async def mark_notification_as_read(
|
||||
notification_id: int,
|
||||
|
|
|
|||
29
surfsense_backend/app/schemas/chat_session_state.py
Normal file
29
surfsense_backend/app/schemas/chat_session_state.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Pydantic schemas for chat session state (live collaboration).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class RespondingUser(BaseModel):
|
||||
"""The user that the AI is currently responding to."""
|
||||
|
||||
id: UUID
|
||||
display_name: str | None = None
|
||||
email: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ChatSessionStateResponse(BaseModel):
|
||||
"""Current session state for a chat thread."""
|
||||
|
||||
id: int
|
||||
thread_id: int
|
||||
responding_to: RespondingUser | None = None
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
@ -281,8 +281,10 @@ async def create_comment(
|
|||
detail="You don't have permission to create comments in this search space",
|
||||
)
|
||||
|
||||
thread = message.thread
|
||||
comment = ChatComment(
|
||||
message_id=message_id,
|
||||
thread_id=thread.id, # Denormalized for efficient Electric subscriptions
|
||||
author_id=user.id,
|
||||
content=content,
|
||||
)
|
||||
|
|
@ -299,7 +301,6 @@ async def create_comment(
|
|||
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
||||
|
||||
# Create notifications for mentioned users (excluding author)
|
||||
thread = message.thread
|
||||
author_name = user.display_name or user.email
|
||||
content_preview = render_mentions(content, user_names)
|
||||
for mentioned_user_id, mention_id in mentions_map.items():
|
||||
|
|
@ -393,8 +394,10 @@ async def create_reply(
|
|||
detail="You don't have permission to create comments in this search space",
|
||||
)
|
||||
|
||||
thread = parent_comment.message.thread
|
||||
reply = ChatComment(
|
||||
message_id=parent_comment.message_id,
|
||||
thread_id=thread.id, # Denormalized for efficient Electric subscriptions
|
||||
parent_id=comment_id,
|
||||
author_id=user.id,
|
||||
content=content,
|
||||
|
|
@ -412,7 +415,6 @@ async def create_reply(
|
|||
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
||||
|
||||
# Create notifications for mentioned users (excluding author)
|
||||
thread = parent_comment.message.thread
|
||||
author_name = user.display_name or user.email
|
||||
content_preview = render_mentions(content, user_names)
|
||||
for mentioned_user_id, mention_id in mentions_map.items():
|
||||
|
|
|
|||
65
surfsense_backend/app/services/chat_session_state_service.py
Normal file
65
surfsense_backend/app/services/chat_session_state_service.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""
|
||||
Service layer for chat session state (live collaboration).
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import ChatSessionState
|
||||
|
||||
|
||||
async def get_session_state(
|
||||
session: AsyncSession,
|
||||
thread_id: int,
|
||||
) -> ChatSessionState | None:
|
||||
"""Get the current session state for a thread."""
|
||||
result = await session.execute(
|
||||
select(ChatSessionState)
|
||||
.options(selectinload(ChatSessionState.ai_responding_to_user))
|
||||
.filter(ChatSessionState.thread_id == thread_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_ai_responding(
|
||||
session: AsyncSession,
|
||||
thread_id: int,
|
||||
user_id: UUID,
|
||||
) -> ChatSessionState:
|
||||
"""Mark AI as responding to a specific user. Uses upsert for atomicity."""
|
||||
now = datetime.now(UTC)
|
||||
upsert_query = insert(ChatSessionState).values(
|
||||
thread_id=thread_id,
|
||||
ai_responding_to_user_id=user_id,
|
||||
updated_at=now,
|
||||
)
|
||||
upsert_query = upsert_query.on_conflict_do_update(
|
||||
index_elements=["thread_id"],
|
||||
set_={
|
||||
"ai_responding_to_user_id": user_id,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
await session.execute(upsert_query)
|
||||
await session.commit()
|
||||
|
||||
return await get_session_state(session, thread_id)
|
||||
|
||||
|
||||
async def clear_ai_responding(
|
||||
session: AsyncSession,
|
||||
thread_id: int,
|
||||
) -> ChatSessionState | None:
|
||||
"""Clear AI responding state when response is complete."""
|
||||
state = await get_session_state(session, thread_id)
|
||||
if state:
|
||||
state.ai_responding_to_user_id = None
|
||||
state.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
|
@ -97,7 +97,7 @@ class ComposioService:
|
|||
config_toolkit = getattr(auth_config, "toolkit", None)
|
||||
if config_toolkit is None:
|
||||
continue
|
||||
|
||||
|
||||
# Extract toolkit name/slug from the object
|
||||
toolkit_name = None
|
||||
if isinstance(config_toolkit, str):
|
||||
|
|
@ -108,18 +108,22 @@ class ComposioService:
|
|||
toolkit_name = config_toolkit.name
|
||||
elif hasattr(config_toolkit, "id"):
|
||||
toolkit_name = config_toolkit.id
|
||||
|
||||
|
||||
# Compare case-insensitively
|
||||
if toolkit_name and toolkit_name.lower() == toolkit_id.lower():
|
||||
logger.info(f"Found auth config {auth_config.id} for toolkit {toolkit_id}")
|
||||
logger.info(
|
||||
f"Found auth config {auth_config.id} for toolkit {toolkit_id}"
|
||||
)
|
||||
return auth_config.id
|
||||
|
||||
|
||||
# Log available auth configs for debugging
|
||||
logger.warning(f"No auth config found for toolkit '{toolkit_id}'. Available auth configs:")
|
||||
logger.warning(
|
||||
f"No auth config found for toolkit '{toolkit_id}'. Available auth configs:"
|
||||
)
|
||||
for auth_config in auth_configs.items:
|
||||
config_toolkit = getattr(auth_config, "toolkit", None)
|
||||
logger.warning(f" - {auth_config.id}: toolkit={config_toolkit}")
|
||||
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list auth configs: {e!s}")
|
||||
|
|
@ -148,7 +152,7 @@ class ComposioService:
|
|||
try:
|
||||
# First, get the auth_config_id for this toolkit
|
||||
auth_config_id = self._get_auth_config_for_toolkit(toolkit_id)
|
||||
|
||||
|
||||
if not auth_config_id:
|
||||
raise ValueError(
|
||||
f"No auth config found for toolkit '{toolkit_id}'. "
|
||||
|
|
@ -200,7 +204,9 @@ class ComposioService:
|
|||
"user_id": getattr(account, "user_id", None),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get connected account {connected_account_id}: {e!s}")
|
||||
logger.error(
|
||||
f"Failed to get connected account {connected_account_id}: {e!s}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def list_all_connections(self) -> list[dict[str, Any]]:
|
||||
|
|
@ -212,15 +218,17 @@ class ComposioService:
|
|||
"""
|
||||
try:
|
||||
accounts_response = self.client.connected_accounts.list()
|
||||
|
||||
|
||||
if hasattr(accounts_response, "items"):
|
||||
accounts = accounts_response.items
|
||||
elif hasattr(accounts_response, "__iter__"):
|
||||
accounts = accounts_response
|
||||
else:
|
||||
logger.warning(f"Unexpected accounts response type: {type(accounts_response)}")
|
||||
logger.warning(
|
||||
f"Unexpected accounts response type: {type(accounts_response)}"
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
result = []
|
||||
for acc in accounts:
|
||||
toolkit_raw = getattr(acc, "toolkit", None)
|
||||
|
|
@ -234,14 +242,16 @@ class ComposioService:
|
|||
toolkit_info = toolkit_raw.name
|
||||
else:
|
||||
toolkit_info = str(toolkit_raw)
|
||||
|
||||
result.append({
|
||||
"id": acc.id,
|
||||
"status": getattr(acc, "status", None),
|
||||
"toolkit": toolkit_info,
|
||||
"user_id": getattr(acc, "user_id", None),
|
||||
})
|
||||
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": acc.id,
|
||||
"status": getattr(acc, "status", None),
|
||||
"toolkit": toolkit_info,
|
||||
"user_id": getattr(acc, "user_id", None),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"DEBUG: Found {len(result)} TOTAL connections in Composio")
|
||||
return result
|
||||
except Exception as e:
|
||||
|
|
@ -261,16 +271,18 @@ class ComposioService:
|
|||
try:
|
||||
logger.info(f"DEBUG: Calling connected_accounts.list(user_id='{user_id}')")
|
||||
accounts_response = self.client.connected_accounts.list(user_id=user_id)
|
||||
|
||||
|
||||
# Handle paginated response (may have .items attribute) or direct list
|
||||
if hasattr(accounts_response, "items"):
|
||||
accounts = accounts_response.items
|
||||
elif hasattr(accounts_response, "__iter__"):
|
||||
accounts = accounts_response
|
||||
else:
|
||||
logger.warning(f"Unexpected accounts response type: {type(accounts_response)}")
|
||||
logger.warning(
|
||||
f"Unexpected accounts response type: {type(accounts_response)}"
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
result = []
|
||||
for acc in accounts:
|
||||
# Extract toolkit info - might be string or object
|
||||
|
|
@ -285,13 +297,15 @@ class ComposioService:
|
|||
toolkit_info = toolkit_raw.name
|
||||
else:
|
||||
toolkit_info = toolkit_raw
|
||||
|
||||
result.append({
|
||||
"id": acc.id,
|
||||
"status": getattr(acc, "status", None),
|
||||
"toolkit": toolkit_info,
|
||||
})
|
||||
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": acc.id,
|
||||
"status": getattr(acc, "status", None),
|
||||
"toolkit": toolkit_info,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(result)} connections for user {user_id}: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
|
|
@ -383,18 +397,24 @@ class ComposioService:
|
|||
return [], None, result.get("error", "Unknown error")
|
||||
|
||||
data = result.get("data", {})
|
||||
logger.info(f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
|
||||
|
||||
logger.info(
|
||||
f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
|
||||
)
|
||||
|
||||
# Handle nested response structure from Composio
|
||||
files = []
|
||||
next_token = None
|
||||
if isinstance(data, dict):
|
||||
# Try direct access first, then nested
|
||||
files = data.get("files", []) or data.get("data", {}).get("files", [])
|
||||
next_token = data.get("nextPageToken") or data.get("next_page_token") or data.get("data", {}).get("nextPageToken")
|
||||
next_token = (
|
||||
data.get("nextPageToken")
|
||||
or data.get("next_page_token")
|
||||
or data.get("data", {}).get("nextPageToken")
|
||||
)
|
||||
elif isinstance(data, list):
|
||||
files = data
|
||||
|
||||
|
||||
logger.info(f"DEBUG: Extracted {len(files)} drive files")
|
||||
return files, next_token, None
|
||||
|
||||
|
|
@ -475,16 +495,22 @@ class ComposioService:
|
|||
return [], result.get("error", "Unknown error")
|
||||
|
||||
data = result.get("data", {})
|
||||
logger.info(f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
|
||||
logger.info(
|
||||
f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
|
||||
)
|
||||
logger.info(f"DEBUG: Gmail full data: {data}")
|
||||
|
||||
|
||||
# Try different possible response structures
|
||||
messages = []
|
||||
if isinstance(data, dict):
|
||||
messages = data.get("messages", []) or data.get("data", {}).get("messages", []) or data.get("emails", [])
|
||||
messages = (
|
||||
data.get("messages", [])
|
||||
or data.get("data", {}).get("messages", [])
|
||||
or data.get("emails", [])
|
||||
)
|
||||
elif isinstance(data, list):
|
||||
messages = data
|
||||
|
||||
|
||||
logger.info(f"DEBUG: Extracted {len(messages)} messages")
|
||||
return messages, None
|
||||
|
||||
|
|
@ -569,16 +595,22 @@ class ComposioService:
|
|||
return [], result.get("error", "Unknown error")
|
||||
|
||||
data = result.get("data", {})
|
||||
logger.info(f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
|
||||
logger.info(
|
||||
f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
|
||||
)
|
||||
logger.info(f"DEBUG: Calendar full data: {data}")
|
||||
|
||||
|
||||
# Try different possible response structures
|
||||
events = []
|
||||
if isinstance(data, dict):
|
||||
events = data.get("items", []) or data.get("data", {}).get("items", []) or data.get("events", [])
|
||||
events = (
|
||||
data.get("items", [])
|
||||
or data.get("data", {}).get("items", [])
|
||||
or data.get("events", [])
|
||||
)
|
||||
elif isinstance(data, list):
|
||||
events = data
|
||||
|
||||
|
||||
logger.info(f"DEBUG: Extracted {len(events)} calendar events")
|
||||
return events, None
|
||||
|
||||
|
|
|
|||
|
|
@ -623,6 +623,28 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
|||
def __init__(self):
|
||||
super().__init__("new_mention")
|
||||
|
||||
async def find_notification_by_mention(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
mention_id: int,
|
||||
) -> Notification | None:
|
||||
"""
|
||||
Find an existing notification by mention ID.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
mention_id: The mention ID to search for
|
||||
|
||||
Returns:
|
||||
Notification if found, None otherwise
|
||||
"""
|
||||
query = select(Notification).where(
|
||||
Notification.type == self.notification_type,
|
||||
Notification.notification_metadata["mention_id"].astext == str(mention_id),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def notify_new_mention(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
|
|
@ -641,11 +663,12 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
|||
) -> Notification:
|
||||
"""
|
||||
Create notification when a user is @mentioned in a comment.
|
||||
Uses mention_id for idempotency to prevent duplicate notifications.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
mentioned_user_id: User who was mentioned
|
||||
mention_id: ID of the mention record
|
||||
mention_id: ID of the mention record (used for idempotency)
|
||||
comment_id: ID of the comment containing the mention
|
||||
message_id: ID of the message being commented on
|
||||
thread_id: ID of the chat thread
|
||||
|
|
@ -658,8 +681,16 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
|||
search_space_id: Search space ID
|
||||
|
||||
Returns:
|
||||
Notification: The created notification
|
||||
Notification: The created or existing notification
|
||||
"""
|
||||
# Check if notification already exists for this mention (idempotency)
|
||||
existing = await self.find_notification_by_mention(session, mention_id)
|
||||
if existing:
|
||||
logger.info(
|
||||
f"Notification already exists for mention {mention_id}, returning existing"
|
||||
)
|
||||
return existing
|
||||
|
||||
title = f"{author_name} mentioned you"
|
||||
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
|
||||
|
||||
|
|
@ -676,21 +707,37 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
|||
"content_preview": content_preview[:200],
|
||||
}
|
||||
|
||||
notification = Notification(
|
||||
user_id=mentioned_user_id,
|
||||
search_space_id=search_space_id,
|
||||
type=self.notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
notification_metadata=metadata,
|
||||
)
|
||||
session.add(notification)
|
||||
await session.commit()
|
||||
await session.refresh(notification)
|
||||
logger.info(
|
||||
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
|
||||
)
|
||||
return notification
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=mentioned_user_id,
|
||||
search_space_id=search_space_id,
|
||||
type=self.notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
notification_metadata=metadata,
|
||||
)
|
||||
session.add(notification)
|
||||
await session.commit()
|
||||
await session.refresh(notification)
|
||||
logger.info(
|
||||
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
|
||||
)
|
||||
return notification
|
||||
except Exception as e:
|
||||
# Handle race condition - if duplicate key error, try to fetch existing
|
||||
await session.rollback()
|
||||
if (
|
||||
"duplicate key" in str(e).lower()
|
||||
or "unique constraint" in str(e).lower()
|
||||
):
|
||||
logger.warning(
|
||||
f"Duplicate notification detected for mention {mention_id}, fetching existing"
|
||||
)
|
||||
existing = await self.find_notification_by_mention(session, mention_id)
|
||||
if existing:
|
||||
return existing
|
||||
# Re-raise if not a duplicate key error or couldn't find existing
|
||||
raise
|
||||
|
||||
|
||||
class NotificationService:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Supports loading LLM configurations from:
|
|||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -27,6 +28,10 @@ from app.agents.new_chat.llm_config import (
|
|||
)
|
||||
from app.db import Document, SurfsenseDocsDocument
|
||||
from app.schemas.new_chat import ChatAttachment
|
||||
from app.services.chat_session_state_service import (
|
||||
clear_ai_responding,
|
||||
set_ai_responding,
|
||||
)
|
||||
from app.services.connector_service import ConnectorService
|
||||
from app.services.new_streaming_service import VercelStreamingService
|
||||
|
||||
|
|
@ -167,9 +172,8 @@ async def stream_new_chat(
|
|||
search_space_id: The search space ID
|
||||
chat_id: The chat ID (used as LangGraph thread_id for memory)
|
||||
session: The database session
|
||||
user_id: The current user's UUID string (for memory tools)
|
||||
user_id: The current user's UUID string (for memory tools and session state)
|
||||
llm_config_id: The LLM configuration ID (default: -1 for first global config)
|
||||
messages: Optional chat history from frontend (list of ChatMessage)
|
||||
attachments: Optional attachments with extracted content
|
||||
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
|
||||
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
|
||||
|
|
@ -183,6 +187,9 @@ async def stream_new_chat(
|
|||
current_text_id: str | None = None
|
||||
|
||||
try:
|
||||
# Mark AI as responding to this user for live collaboration
|
||||
if user_id:
|
||||
await set_ai_responding(session, chat_id, UUID(user_id))
|
||||
# Load LLM config - supports both YAML (negative IDs) and database (positive IDs)
|
||||
agent_config: AgentConfig | None = None
|
||||
|
||||
|
|
@ -1147,3 +1154,7 @@ async def stream_new_chat(
|
|||
yield streaming_service.format_finish_step()
|
||||
yield streaming_service.format_finish()
|
||||
yield streaming_service.format_done()
|
||||
|
||||
finally:
|
||||
# Clear AI responding state for live collaboration
|
||||
await clear_ai_responding(session, chat_id)
|
||||
|
|
|
|||
|
|
@ -144,7 +144,9 @@ async def index_composio_connector(
|
|||
# Get toolkit ID from config
|
||||
toolkit_id = connector.config.get("toolkit_id")
|
||||
if not toolkit_id:
|
||||
error_msg = f"Composio connector {connector_id} has no toolkit_id configured"
|
||||
error_msg = (
|
||||
f"Composio connector {connector_id} has no toolkit_id configured"
|
||||
)
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, error_msg, {"error_type": "MissingToolkitId"}
|
||||
)
|
||||
|
|
@ -287,8 +289,14 @@ async def _index_composio_google_drive(
|
|||
try:
|
||||
# Handle both standard Google API and potential Composio variations
|
||||
file_id = file_info.get("id", "") or file_info.get("fileId", "")
|
||||
file_name = file_info.get("name", "") or file_info.get("fileName", "") or "Untitled"
|
||||
mime_type = file_info.get("mimeType", "") or file_info.get("mime_type", "")
|
||||
file_name = (
|
||||
file_info.get("name", "")
|
||||
or file_info.get("fileName", "")
|
||||
or "Untitled"
|
||||
)
|
||||
mime_type = file_info.get("mimeType", "") or file_info.get(
|
||||
"mime_type", ""
|
||||
)
|
||||
|
||||
if not file_id:
|
||||
documents_skipped += 1
|
||||
|
|
@ -309,12 +317,15 @@ async def _index_composio_google_drive(
|
|||
)
|
||||
|
||||
# Get file content
|
||||
content, content_error = await composio_connector.get_drive_file_content(
|
||||
file_id
|
||||
)
|
||||
(
|
||||
content,
|
||||
content_error,
|
||||
) = await composio_connector.get_drive_file_content(file_id)
|
||||
|
||||
if content_error or not content:
|
||||
logger.warning(f"Could not get content for file {file_name}: {content_error}")
|
||||
logger.warning(
|
||||
f"Could not get content for file {file_name}: {content_error}"
|
||||
)
|
||||
# Use metadata as content fallback
|
||||
markdown_content = f"# {file_name}\n\n"
|
||||
markdown_content += f"**File ID:** {file_id}\n"
|
||||
|
|
@ -344,12 +355,19 @@ async def _index_composio_google_drive(
|
|||
"mime_type": mime_type,
|
||||
"document_type": "Google Drive File (Composio)",
|
||||
}
|
||||
summary_content, summary_embedding = await generate_document_summary(
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||
summary_content = (
|
||||
f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
|
|
@ -382,12 +400,19 @@ async def _index_composio_google_drive(
|
|||
"mime_type": mime_type,
|
||||
"document_type": "Google Drive File (Composio)",
|
||||
}
|
||||
summary_content, summary_embedding = await generate_document_summary(
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||
summary_content = (
|
||||
f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
|
|
@ -527,11 +552,15 @@ async def _index_composio_gmail(
|
|||
date_str = value
|
||||
|
||||
# Format to markdown using the full message data
|
||||
markdown_content = composio_connector.format_gmail_message_to_markdown(message)
|
||||
markdown_content = composio_connector.format_gmail_message_to_markdown(
|
||||
message
|
||||
)
|
||||
|
||||
# Generate unique identifier
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.COMPOSIO_CONNECTOR, f"gmail_{message_id}", search_space_id
|
||||
DocumentType.COMPOSIO_CONNECTOR,
|
||||
f"gmail_{message_id}",
|
||||
search_space_id,
|
||||
)
|
||||
|
||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||
|
|
@ -560,12 +589,19 @@ async def _index_composio_gmail(
|
|||
"sender": sender,
|
||||
"document_type": "Gmail Message (Composio)",
|
||||
}
|
||||
summary_content, summary_embedding = await generate_document_summary(
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||
summary_content = (
|
||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
|
|
@ -600,12 +636,19 @@ async def _index_composio_gmail(
|
|||
"sender": sender,
|
||||
"document_type": "Gmail Message (Composio)",
|
||||
}
|
||||
summary_content, summary_embedding = await generate_document_summary(
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||
summary_content = (
|
||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
|
|
@ -728,18 +771,24 @@ async def _index_composio_google_calendar(
|
|||
try:
|
||||
# Handle both standard Google API and potential Composio variations
|
||||
event_id = event.get("id", "") or event.get("eventId", "")
|
||||
summary = event.get("summary", "") or event.get("title", "") or "No Title"
|
||||
summary = (
|
||||
event.get("summary", "") or event.get("title", "") or "No Title"
|
||||
)
|
||||
|
||||
if not event_id:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Format to markdown
|
||||
markdown_content = composio_connector.format_calendar_event_to_markdown(event)
|
||||
markdown_content = composio_connector.format_calendar_event_to_markdown(
|
||||
event
|
||||
)
|
||||
|
||||
# Generate unique identifier
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.COMPOSIO_CONNECTOR, f"calendar_{event_id}", search_space_id
|
||||
DocumentType.COMPOSIO_CONNECTOR,
|
||||
f"calendar_{event_id}",
|
||||
search_space_id,
|
||||
)
|
||||
|
||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||
|
|
@ -772,14 +821,19 @@ async def _index_composio_google_calendar(
|
|||
"start_time": start_time,
|
||||
"document_type": "Google Calendar Event (Composio)",
|
||||
}
|
||||
summary_content, summary_embedding = await generate_document_summary(
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||
if location:
|
||||
summary_content += f"\nLocation: {location}"
|
||||
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
|
|
@ -814,14 +868,21 @@ async def _index_composio_google_calendar(
|
|||
"start_time": start_time,
|
||||
"document_type": "Google Calendar Event (Composio)",
|
||||
}
|
||||
summary_content, summary_embedding = await generate_document_summary(
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||
summary_content = (
|
||||
f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||
)
|
||||
if location:
|
||||
summary_content += f"\nLocation: {location}"
|
||||
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
|
|
@ -874,5 +935,7 @@ async def _index_composio_google_calendar(
|
|||
return documents_indexed, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True)
|
||||
logger.error(
|
||||
f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True
|
||||
)
|
||||
return 0, f"Failed to index Google Calendar via Composio: {e!s}"
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ async def index_github_repos(
|
|||
if github_pat:
|
||||
logger.info("Using GitHub PAT for authentication (private repos supported)")
|
||||
else:
|
||||
logger.info("No GitHub PAT provided - only public repositories can be indexed")
|
||||
logger.info(
|
||||
"No GitHub PAT provided - only public repositories can be indexed"
|
||||
)
|
||||
|
||||
# 3. Initialize GitHub connector with gitingest backend
|
||||
await task_logger.log_task_progress(
|
||||
|
|
@ -308,9 +310,7 @@ async def _process_repository_digest(
|
|||
if existing_document:
|
||||
# Document exists - check if content has changed
|
||||
if existing_document.content_hash == content_hash:
|
||||
logger.info(
|
||||
f"Repository {repo_full_name} unchanged. Skipping."
|
||||
)
|
||||
logger.info(f"Repository {repo_full_name} unchanged. Skipping.")
|
||||
return 0
|
||||
else:
|
||||
logger.info(
|
||||
|
|
@ -341,7 +341,7 @@ async def _process_repository_digest(
|
|||
summary_content = (
|
||||
f"# Repository: {repo_full_name}\n\n"
|
||||
f"## File Structure\n\n{digest.tree}\n\n"
|
||||
f"## File Contents (truncated)\n\n{digest.content[:MAX_DIGEST_CHARS - len(digest.tree) - 200]}..."
|
||||
f"## File Contents (truncated)\n\n{digest.content[: MAX_DIGEST_CHARS - len(digest.tree) - 200]}..."
|
||||
)
|
||||
|
||||
summary_text, summary_embedding = await generate_document_summary(
|
||||
|
|
@ -362,9 +362,7 @@ async def _process_repository_digest(
|
|||
# This preserves file-level granularity in search
|
||||
chunks_data = await create_document_chunks(digest.content)
|
||||
except Exception as chunk_err:
|
||||
logger.error(
|
||||
f"Failed to chunk repository {repo_full_name}: {chunk_err}"
|
||||
)
|
||||
logger.error(f"Failed to chunk repository {repo_full_name}: {chunk_err}")
|
||||
# Fall back to a simpler chunking approach
|
||||
chunks_data = await _simple_chunk_content(digest.content)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,12 +39,6 @@ export default function DashboardLayout({
|
|||
icon: "SquareLibrary",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: `/dashboard/${search_space_id}/logs`,
|
||||
icon: "Logs",
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
// extractWriteTodosFromContent,
|
||||
hydratePlanStateAtom,
|
||||
} from "@/atoms/chat/plan-state.atom";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
|
|
@ -32,7 +33,9 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
|||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { SaveMemoryToolUI, RecallMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||
|
|
@ -258,6 +261,53 @@ export default function NewChatPage() {
|
|||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Live collaboration: sync session state and messages via Electric SQL
|
||||
useChatSessionStateSync(threadId);
|
||||
const { data: membersData } = useAtomValue(membersAtom);
|
||||
|
||||
const handleElectricMessagesUpdate = useCallback(
|
||||
(
|
||||
electricMessages: {
|
||||
id: number;
|
||||
thread_id: number;
|
||||
role: string;
|
||||
content: unknown;
|
||||
author_id: string | null;
|
||||
created_at: string;
|
||||
}[]
|
||||
) => {
|
||||
if (isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => {
|
||||
if (electricMessages.length < prev.length) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return electricMessages.map((msg) => {
|
||||
const member = msg.author_id
|
||||
? membersData?.find((m) => m.user_id === msg.author_id)
|
||||
: null;
|
||||
|
||||
return convertToThreadMessage({
|
||||
id: msg.id,
|
||||
thread_id: msg.thread_id,
|
||||
role: msg.role.toLowerCase() as "user" | "assistant" | "system",
|
||||
content: msg.content,
|
||||
author_id: msg.author_id,
|
||||
created_at: msg.created_at,
|
||||
author_display_name: member?.user_display_name ?? null,
|
||||
author_avatar_url: member?.user_avatar_url ?? null,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
[isRunning, membersData]
|
||||
);
|
||||
|
||||
useMessagesElectric(threadId, handleElectricMessagesUpdate);
|
||||
|
||||
// Create the attachment adapter for file processing
|
||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||
|
||||
|
|
@ -368,7 +418,7 @@ export default function NewChatPage() {
|
|||
initializeThread();
|
||||
}, [initializeThread]);
|
||||
|
||||
// Handle scroll to comment from URL query params (e.g., from notification click)
|
||||
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||
const searchParams = useSearchParams();
|
||||
const targetCommentId = searchParams.get("commentId");
|
||||
|
||||
|
|
@ -587,8 +637,6 @@ export default function NewChatPage() {
|
|||
content: persistContent,
|
||||
})
|
||||
.then(() => {
|
||||
// For new threads, the backend updates the title from the first user message
|
||||
// Invalidate threads query so sidebar shows the updated title in real-time
|
||||
if (isNewThread) {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,29 +3,38 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Calendar,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Crown,
|
||||
Edit2,
|
||||
FileText,
|
||||
Hash,
|
||||
Link2,
|
||||
LinkIcon,
|
||||
Loader2,
|
||||
Logs,
|
||||
type LucideIcon,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Plug,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -512,6 +521,25 @@ export default function TeamManagementPage() {
|
|||
|
||||
// ============ Members Tab ============
|
||||
|
||||
// Helper function to get avatar initials
|
||||
function getAvatarInitials(member: Membership): string {
|
||||
// Try display name first
|
||||
if (member.user_display_name) {
|
||||
const parts = member.user_display_name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return member.user_display_name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
// Try email
|
||||
if (member.user_email) {
|
||||
const emailName = member.user_email.split("@")[0];
|
||||
return emailName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
// Fallback
|
||||
return "U";
|
||||
}
|
||||
|
||||
function MembersTab({
|
||||
members,
|
||||
roles,
|
||||
|
|
@ -560,7 +588,7 @@ function MembersTab({
|
|||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search members..."
|
||||
placeholder="Search members"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
|
@ -573,10 +601,30 @@ function MembersTab({
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">Member</TableHead>
|
||||
<TableHead className="px-2 md:px-4">Role</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Joined</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Member
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="px-2 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Role
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Joined
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Actions
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -601,19 +649,36 @@ function MembersTab({
|
|||
<TableCell className="py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||
<div className="flex items-center gap-1.5 md:gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-2 ring-background">
|
||||
<User className="h-4 w-4 md:h-5 md:w-5 text-primary" />
|
||||
</div>
|
||||
{member.user_avatar_url ? (
|
||||
<Image
|
||||
src={member.user_avatar_url}
|
||||
alt={member.user_display_name || member.user_email || "User"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-8 w-8 md:h-10 md:w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
|
||||
<span className="text-xs md:text-sm font-medium text-primary">
|
||||
{getAvatarInitials(member)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{member.is_owner && (
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center ring-2 ring-background">
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center">
|
||||
<Crown className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs md:text-sm truncate">
|
||||
{member.user_email || "Unknown"}
|
||||
{member.user_display_name || member.user_email || "Unknown"}
|
||||
</p>
|
||||
{member.user_display_name && member.user_email && (
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground truncate">
|
||||
{member.user_email}
|
||||
</p>
|
||||
)}
|
||||
{member.is_owner && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
|
@ -640,29 +705,21 @@ function MembersTab({
|
|||
<SelectItem value="none">No role</SelectItem>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-3 w-3" />
|
||||
{role.name}
|
||||
</div>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-[10px] md:text-xs py-0 md:py-0.5"
|
||||
>
|
||||
<Shield className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||
<Badge variant="secondary" className="text-[10px] md:text-xs py-0 md:py-0.5">
|
||||
{member.role?.name || "No role"}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(member.joined_at).toLocaleDateString()}
|
||||
</div>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||
{canRemove && !member.is_owner && (
|
||||
|
|
@ -708,13 +765,137 @@ function MembersTab({
|
|||
);
|
||||
}
|
||||
|
||||
// ============ Role Permissions Display ============
|
||||
|
||||
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = {
|
||||
documents: { label: "Documents", icon: FileText, order: 1 },
|
||||
chats: { label: "Chats", icon: MessageSquare, order: 2 },
|
||||
comments: { label: "Comments", icon: MessageCircle, order: 3 },
|
||||
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
|
||||
podcasts: { label: "Podcasts", icon: Mic, order: 5 },
|
||||
connectors: { label: "Connectors", icon: Plug, order: 6 },
|
||||
logs: { label: "Logs", icon: Logs, order: 7 },
|
||||
members: { label: "Members", icon: Users, order: 8 },
|
||||
roles: { label: "Roles", icon: Shield, order: 9 },
|
||||
settings: { label: "Settings", icon: Settings, order: 10 },
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: "Create",
|
||||
read: "Read",
|
||||
update: "Update",
|
||||
delete: "Delete",
|
||||
invite: "Invite",
|
||||
view: "View",
|
||||
remove: "Remove",
|
||||
manage_roles: "Manage Roles",
|
||||
};
|
||||
|
||||
function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
|
||||
if (permissions.includes("*")) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center shrink-0">
|
||||
<Crown className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">Full Access</p>
|
||||
<p className="text-xs text-muted-foreground">All permissions granted</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group permissions by category
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const perm of permissions) {
|
||||
const [category, action] = perm.split(":");
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category].push(action);
|
||||
}
|
||||
|
||||
// Sort categories by predefined order
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
|
||||
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const categoryCount = sortedCategories.length;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg border border-border/50 bg-muted/30 hover:bg-muted/50 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{permissions.length} Permissions</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across {categoryCount} {categoryCount === 1 ? "category" : "categories"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">View details</div>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
|
||||
<DialogHeader className="p-4 md:p-5 border-b">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
Role Permissions
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{permissions.length} permissions across {categoryCount} categories
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[55vh]">
|
||||
<div className="divide-y divide-border/50">
|
||||
{sortedCategories.map((category) => {
|
||||
const actions = grouped[category];
|
||||
const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText };
|
||||
const IconComponent = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{config.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] font-medium"
|
||||
>
|
||||
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Roles Tab ============
|
||||
|
||||
function RolesTab({
|
||||
roles,
|
||||
groupedPermissions,
|
||||
groupedPermissions: _groupedPermissions,
|
||||
loading,
|
||||
onUpdateRole,
|
||||
onUpdateRole: _onUpdateRole,
|
||||
onDeleteRole,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
|
|
@ -852,32 +1033,7 @@ function RolesTab({
|
|||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Permissions ({role.permissions.includes("*") ? "All" : role.permissions.length})
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.permissions.includes("*") ? (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-gradient-to-r from-amber-500 to-orange-500"
|
||||
>
|
||||
Full Access
|
||||
</Badge>
|
||||
) : (
|
||||
role.permissions.slice(0, 5).map((perm) => (
|
||||
<Badge key={perm} variant="secondary" className="text-xs">
|
||||
{perm.replace(":", " ")}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{!role.permissions.includes("*") && role.permissions.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{role.permissions.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RolePermissionsDisplay permissions={role.permissions} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
|
@ -1500,7 +1656,11 @@ function CreateRoleDialog({
|
|||
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => toggleCategory(category)}
|
||||
|
|
@ -1508,19 +1668,21 @@ function CreateRoleDialog({
|
|||
<span className="text-sm font-medium capitalize">
|
||||
{category} ({categorySelected}/{perms.length})
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2 ml-6">
|
||||
{perms.map((perm) => (
|
||||
<label
|
||||
<button
|
||||
type="button"
|
||||
key={perm.value}
|
||||
className="flex items-center gap-2 cursor-pointer text-left"
|
||||
onClick={() => togglePermission(perm.value)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(perm.value)}
|
||||
onCheckedChange={() => togglePermission(perm.value)}
|
||||
/>
|
||||
<span className="text-xs">{perm.value.split(":")[1]}</span>
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
15
surfsense_web/atoms/chat/chat-session-state.atom.ts
Normal file
15
surfsense_web/atoms/chat/chat-session-state.atom.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
|
||||
export interface ChatSessionStateData {
|
||||
threadId: number;
|
||||
isAiResponding: boolean;
|
||||
respondingToUserId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global chat session state atom.
|
||||
* Updated by useChatSessionStateSync hook, read anywhere.
|
||||
*/
|
||||
export const chatSessionStateAtom = atom<ChatSessionStateData | null>(null);
|
||||
|
|
@ -9,7 +9,7 @@ export const membersAtom = atomWithQuery((get) => {
|
|||
return {
|
||||
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
||||
queryFn: async () => {
|
||||
if (!searchSpaceId) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatSessionStatusProps {
|
||||
isAiResponding: boolean;
|
||||
respondingToUserId: string | null;
|
||||
currentUserId: string | null;
|
||||
members: Array<{
|
||||
user_id: string;
|
||||
user_display_name?: string | null;
|
||||
user_email?: string | null;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
|
||||
isAiResponding,
|
||||
respondingToUserId,
|
||||
currentUserId,
|
||||
members,
|
||||
className,
|
||||
}) => {
|
||||
if (!isAiResponding || !respondingToUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (respondingToUserId === currentUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const respondingUser = members.find((m) => m.user_id === respondingToUserId);
|
||||
const displayName =
|
||||
respondingUser?.user_display_name || respondingUser?.user_email || "another user";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground bg-muted/50 rounded-lg",
|
||||
"animate-in fade-in slide-in-from-bottom-2 duration-300 ease-out",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>Currently responding to {displayName}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -186,12 +186,10 @@ export const ConnectorIndicator: FC = () => {
|
|||
) : viewingComposio && searchSpaceId ? (
|
||||
<ComposioToolkitView
|
||||
searchSpaceId={searchSpaceId}
|
||||
connectedToolkits={
|
||||
(connectors || [])
|
||||
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
|
||||
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
|
||||
.filter(Boolean)
|
||||
}
|
||||
connectedToolkits={(connectors || [])
|
||||
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
|
||||
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
|
||||
.filter(Boolean)}
|
||||
onBack={handleBackFromComposio}
|
||||
onConnectToolkit={handleConnectComposioToolkit}
|
||||
isConnecting={connectingComposioToolkit !== null}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|||
import { isSelfHosted } from "@/lib/env-config";
|
||||
import { ConnectorCard } from "../components/connector-card";
|
||||
import { ComposioConnectorCard } from "../components/composio-connector-card";
|
||||
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS, COMPOSIO_CONNECTORS } from "../constants/connector-constants";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
CRAWLERS,
|
||||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
|
||||
/**
|
||||
|
|
@ -164,7 +169,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
)}
|
||||
|
||||
{/* Composio Integrations */}
|
||||
{filteredComposio.length > 0 && onOpenComposio && (
|
||||
{/* {filteredComposio.length > 0 && onOpenComposio && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</h3>
|
||||
|
|
@ -185,7 +190,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* More Integrations */}
|
||||
{filteredOther.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import {
|
|||
Calendar,
|
||||
Check,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Github,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Mail,
|
||||
HardDrive,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
|
@ -82,17 +82,65 @@ const getToolkitIcon = (toolkitId: string, className?: string) => {
|
|||
|
||||
switch (toolkitId) {
|
||||
case "googledrive":
|
||||
return <Image src="/connectors/google-drive.svg" alt="Google Drive" width={20} height={20} className={iconClass} />;
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-drive.svg"
|
||||
alt="Google Drive"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "gmail":
|
||||
return <Image src="/connectors/google-gmail.svg" alt="Gmail" width={20} height={20} className={iconClass} />;
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-gmail.svg"
|
||||
alt="Gmail"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "googlecalendar":
|
||||
return <Image src="/connectors/google-calendar.svg" alt="Google Calendar" width={20} height={20} className={iconClass} />;
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-calendar.svg"
|
||||
alt="Google Calendar"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "slack":
|
||||
return <Image src="/connectors/slack.svg" alt="Slack" width={20} height={20} className={iconClass} />;
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/slack.svg"
|
||||
alt="Slack"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "notion":
|
||||
return <Image src="/connectors/notion.svg" alt="Notion" width={20} height={20} className={iconClass} />;
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/notion.svg"
|
||||
alt="Notion"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "github":
|
||||
return <Image src="/connectors/github.svg" alt="GitHub" width={20} height={20} className={iconClass} />;
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/github.svg"
|
||||
alt="GitHub"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Zap className={iconClass} />;
|
||||
}
|
||||
|
|
@ -139,9 +187,7 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
|||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
||||
Composio
|
||||
</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">Composio</h2>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||
Connect 100+ apps with managed OAuth - no verification needed
|
||||
</p>
|
||||
|
|
@ -165,12 +211,16 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
|||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
>
|
||||
Indexable
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect Google services via Composio's verified OAuth app. Your data will be indexed and searchable.
|
||||
Connect Google services via Composio's verified OAuth app. Your data will be
|
||||
indexed and searchable.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{indexableToolkits.map((toolkit) => {
|
||||
|
|
@ -201,16 +251,17 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
|||
{getToolkitIcon(toolkit.id, "size-5")}
|
||||
</div>
|
||||
{isConnected && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
>
|
||||
<Check className="size-3 mr-0.5" />
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">
|
||||
{toolkit.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
|
|
@ -242,12 +293,16 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
|||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
>
|
||||
Coming Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect these services for future indexing support. Currently available for connection only.
|
||||
Connect these services for future indexing support. Currently available for connection
|
||||
only.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
|
||||
{nonIndexableToolkits.map((toolkit) => (
|
||||
|
|
@ -264,9 +319,7 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
|||
</Badge>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">
|
||||
{toolkit.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -289,8 +342,9 @@ export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
|||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Composio provides pre-verified OAuth apps, so you don't need to wait for Google app verification.
|
||||
Your data is securely processed through Composio's managed authentication.
|
||||
Composio provides pre-verified OAuth apps, so you don't need to wait for Google
|
||||
app verification. Your data is securely processed through Composio's managed
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,11 +26,13 @@ import {
|
|||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
|
|
@ -39,6 +41,7 @@ import {
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
|
|
@ -59,6 +62,7 @@ import {
|
|||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThreadProps {
|
||||
|
|
@ -86,6 +90,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
|||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
autoScroll
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||
showGutter && "lg:pr-30"
|
||||
|
|
@ -215,7 +220,7 @@ const Composer: FC = () => {
|
|||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const { search_space_id } = useParams();
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const composerRuntime = useComposerRuntime();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
|
|
@ -223,6 +228,23 @@ const Composer: FC = () => {
|
|||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
// Live collaboration state
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
const threadId = useMemo(() => {
|
||||
if (Array.isArray(chat_id) && chat_id.length > 0) {
|
||||
return Number.parseInt(chat_id[0], 10) || null;
|
||||
}
|
||||
return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
|
||||
}, [chat_id]);
|
||||
const sessionState = useAtomValue(chatSessionStateAtom);
|
||||
const isAiResponding = sessionState?.isAiResponding ?? false;
|
||||
const respondingToUserId = sessionState?.respondingToUserId ?? null;
|
||||
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
|
||||
|
||||
// Sync comments for the entire thread via Electric SQL (one subscription per thread)
|
||||
useCommentsElectric(threadId);
|
||||
|
||||
// Auto-focus editor on new chat page after mount
|
||||
useEffect(() => {
|
||||
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
||||
|
|
@ -298,9 +320,9 @@ const Composer: FC = () => {
|
|||
[showDocumentPopover]
|
||||
);
|
||||
|
||||
// Submit message (blocked during streaming or when document picker is open)
|
||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isThreadRunning) {
|
||||
if (isThreadRunning || isBlockedByOtherUser) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
|
|
@ -315,6 +337,7 @@ const Composer: FC = () => {
|
|||
}, [
|
||||
showDocumentPopover,
|
||||
isThreadRunning,
|
||||
isBlockedByOtherUser,
|
||||
composerRuntime,
|
||||
setMentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
|
|
@ -374,7 +397,13 @@ const Composer: FC = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||
<ChatSessionStatus
|
||||
isAiResponding={isAiResponding}
|
||||
respondingToUserId={respondingToUserId}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
members={members ?? []}
|
||||
/>
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||
<ComposerAttachments />
|
||||
{/* Inline editor with @mention support */}
|
||||
|
|
@ -417,13 +446,17 @@ const Composer: FC = () => {
|
|||
/>,
|
||||
document.body
|
||||
)}
|
||||
<ComposerAction />
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerAction: FC = () => {
|
||||
interface ComposerActionProps {
|
||||
isBlockedByOtherUser?: boolean;
|
||||
}
|
||||
|
||||
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||
// Check if any attachments are still being processed (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
||||
|
|
@ -458,7 +491,8 @@ const ComposerAction: FC = () => {
|
|||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
|
||||
const isSendDisabled =
|
||||
hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
|
|
@ -487,13 +521,15 @@ const ComposerAction: FC = () => {
|
|||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
!hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
isBlockedByOtherUser
|
||||
? "Wait for AI to finish responding"
|
||||
: !hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function CommentPanel({
|
|||
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
||||
>
|
||||
{hasThreads && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
|
||||
<div className="space-y-4 p-4">
|
||||
{threads.map((thread) => (
|
||||
<CommentThread
|
||||
|
|
@ -106,7 +106,7 @@ export function CommentPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3">
|
||||
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
|
||||
|
|
@ -15,22 +22,39 @@ export function CommentSheet({
|
|||
}: CommentSheetProps) {
|
||||
const isBottomSheet = side === "bottom";
|
||||
|
||||
// Use Drawer for mobile (bottom), Sheet for medium screens (right)
|
||||
if (isBottomSheet) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={onOpenChange} shouldScaleBackground={false}>
|
||||
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
Comments
|
||||
{commentCount > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{commentCount}
|
||||
</span>
|
||||
)}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// Use Sheet for medium screens (right side)
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side={side}
|
||||
className={cn(
|
||||
"flex flex-col gap-0 overflow-hidden p-0",
|
||||
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
|
||||
)}
|
||||
className={cn("flex flex-col gap-0 overflow-hidden p-0 h-full w-full max-w-md")}
|
||||
>
|
||||
{/* Drag handle indicator - only for bottom sheet */}
|
||||
{isBottomSheet && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
<SheetHeader className={cn("flex-shrink-0 border-b px-4", isBottomSheet ? "pb-3" : "py-4")}>
|
||||
<SheetHeader className="flex-shrink-0 px-4 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
Comments
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export function DashboardBreadcrumb() {
|
|||
|
||||
const breadcrumbs = generateBreadcrumbs(pathname);
|
||||
|
||||
if (breadcrumbs.length <= 1) {
|
||||
if (breadcrumbs.length === 0) {
|
||||
return null; // Don't show breadcrumbs for root dashboard
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { cleanupElectric } from "@/lib/electric/client";
|
||||
|
|
@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
|||
import { LayoutShell } from "../ui/shell";
|
||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
|
||||
|
||||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
|
|
@ -59,8 +61,8 @@ export function LayoutDataProvider({
|
|||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: null;
|
||||
|
||||
// Fetch current search space
|
||||
const { data: searchSpace } = useQuery({
|
||||
// Fetch current search space (for caching purposes)
|
||||
useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||
enabled: !!searchSpaceId,
|
||||
|
|
@ -77,9 +79,25 @@ export function LayoutDataProvider({
|
|||
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
||||
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
||||
|
||||
// Inbox sidebar state
|
||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||
|
||||
// Inbox hook
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
const {
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading: inboxLoading,
|
||||
loadingMore: inboxLoadingMore,
|
||||
hasMore: inboxHasMore,
|
||||
loadMore: inboxLoadMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||
|
|
@ -150,13 +168,14 @@ export function LayoutDataProvider({
|
|||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: `/dashboard/${searchSpaceId}/logs`,
|
||||
icon: Logs,
|
||||
isActive: pathname?.includes("/logs"),
|
||||
title: "Inbox",
|
||||
url: "#inbox", // Special URL to indicate this is handled differently
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname]
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -248,6 +267,11 @@ export function LayoutDataProvider({
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
// Handle inbox specially - open sidebar instead of navigating
|
||||
if (item.url === "#inbox") {
|
||||
setIsInboxSidebarOpen(true);
|
||||
return;
|
||||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router]
|
||||
|
|
@ -517,6 +541,20 @@ export function LayoutDataProvider({
|
|||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
|
||||
{/* Inbox Sidebar */}
|
||||
<InboxSidebar
|
||||
open={isInboxSidebarOpen}
|
||||
onOpenChange={setIsInboxSidebarOpen}
|
||||
inboxItems={inboxItems}
|
||||
unreadCount={unreadCount}
|
||||
loading={inboxLoading}
|
||||
loadingMore={inboxLoadingMore}
|
||||
hasMore={inboxHasMore}
|
||||
loadMore={inboxLoadMore}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
/>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
<CreateSearchSpaceDialog
|
||||
open={isCreateSearchSpaceDialogOpen}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useAtomValue } from "jotai";
|
|||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { NotificationButton } from "@/components/notifications/NotificationButton";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface HeaderProps {
|
||||
|
|
@ -55,8 +54,6 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
|||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<NotificationButton />
|
||||
{/* Share button - only show on chat pages when thread exists */}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
|
|
@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({
|
|||
aria-label={t("chats") || "Private Chats"}
|
||||
>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
|
|||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
!showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Active ({activeCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Archived ({archivedCount})
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="active"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<MessageCircleMore className="h-4 w-4" />
|
||||
<span>Active</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<span>Archived</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
|
|
@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({
|
|||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
|
|
@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({
|
|||
aria-label={t("shared_chats") || "Shared Chats"}
|
||||
>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
|
|||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
!showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Active ({activeCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Archived ({archivedCount})
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="active"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<MessageCircleMore className="h-4 w-4" />
|
||||
<span>Active</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<span>Archived</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
|
|
@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({
|
|||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
|
|||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="sr-only">{t("more_options")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
|
|
@ -0,0 +1,854 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
AtSign,
|
||||
BellDot,
|
||||
Check,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type ConnectorIndexingMetadata,
|
||||
isConnectorIndexingMetadata,
|
||||
isNewMentionMetadata,
|
||||
type NewMentionMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Get initials from name or email for avatar fallback
|
||||
*/
|
||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
if (email) {
|
||||
const localPart = email.split("@")[0];
|
||||
return localPart.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return "U";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for connector type
|
||||
*/
|
||||
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||
LINEAR_CONNECTOR: "Linear",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||
DISCORD_CONNECTOR: "Discord",
|
||||
JIRA_CONNECTOR: "Jira",
|
||||
CONFLUENCE_CONNECTOR: "Confluence",
|
||||
BOOKSTACK_CONNECTOR: "BookStack",
|
||||
CLICKUP_CONNECTOR: "ClickUp",
|
||||
AIRTABLE_CONNECTOR: "Airtable",
|
||||
LUMA_CONNECTOR: "Luma",
|
||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||
WEBCRAWLER_CONNECTOR: "Web Crawler",
|
||||
YOUTUBE_CONNECTOR: "YouTube",
|
||||
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||
MCP_CONNECTOR: "MCP",
|
||||
TAVILY_API: "Tavily",
|
||||
SEARXNG_API: "SearXNG",
|
||||
LINKUP_API: "Linkup",
|
||||
BAIDU_SEARCH_API: "Baidu",
|
||||
};
|
||||
|
||||
return (
|
||||
displayNames[connectorType] ||
|
||||
connectorType
|
||||
.replace(/_/g, " ")
|
||||
.replace(/CONNECTOR|API/gi, "")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
type InboxTab = "mentions" | "status";
|
||||
type InboxFilter = "all" | "unread";
|
||||
|
||||
interface InboxSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inboxItems: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function InboxSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading,
|
||||
loadingMore = false,
|
||||
hasMore = false,
|
||||
loadMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onCloseMobileSidebar,
|
||||
}: InboxSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Dropdown state for filter menu (desktop only)
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
// Drawer state for filter menu (mobile only)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
||||
// Prefetch trigger ref - placed on item near the end
|
||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Reset connector filter when switching away from status tab
|
||||
useEffect(() => {
|
||||
if (activeTab !== "status") {
|
||||
setSelectedConnector(null);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Split items by type
|
||||
const mentionItems = useMemo(
|
||||
() => inboxItems.filter((item) => item.type === "new_mention"),
|
||||
[inboxItems]
|
||||
);
|
||||
|
||||
const statusItems = useMemo(
|
||||
() =>
|
||||
inboxItems.filter(
|
||||
(item) => item.type === "connector_indexing" || item.type === "document_processing"
|
||||
),
|
||||
[inboxItems]
|
||||
);
|
||||
|
||||
// Get unique connector types from status items for filtering
|
||||
const uniqueConnectorTypes = useMemo(() => {
|
||||
const connectorTypes = new Set<string>();
|
||||
|
||||
statusItems
|
||||
.filter((item) => item.type === "connector_indexing")
|
||||
.forEach((item) => {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
connectorTypes.add(item.metadata.connector_type);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(connectorTypes).map((type) => ({
|
||||
type,
|
||||
displayName: getConnectorTypeDisplayName(type),
|
||||
}));
|
||||
}, [statusItems]);
|
||||
|
||||
// Get items for current tab
|
||||
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
|
||||
|
||||
// Filter items based on filter type, connector filter, and search query
|
||||
const filteredItems = useMemo(() => {
|
||||
let items = currentTabItems;
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
items = items.filter((item) => !item.read);
|
||||
}
|
||||
|
||||
// Apply connector filter (only for status tab)
|
||||
if (activeTab === "status" && selectedConnector) {
|
||||
items = items.filter((item) => {
|
||||
if (item.type === "connector_indexing") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
return item.metadata.connector_type === selectedConnector;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false; // Hide document_processing when a specific connector is selected
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||
|
||||
// Intersection Observer for infinite scroll with prefetching
|
||||
// Only active when not searching (search results are client-side filtered)
|
||||
useEffect(() => {
|
||||
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// When trigger element is visible, load more
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null, // viewport
|
||||
rootMargin: "100px", // Start loading 100px before visible
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (prefetchTriggerRef.current) {
|
||||
observer.observe(prefetchTriggerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
|
||||
|
||||
// Count unread items per tab
|
||||
const unreadMentionsCount = useMemo(() => {
|
||||
return mentionItems.filter((item) => !item.read).length;
|
||||
}, [mentionItems]);
|
||||
|
||||
const unreadStatusCount = useMemo(() => {
|
||||
return statusItems.filter((item) => !item.read).length;
|
||||
}, [statusItems]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
if (!item.read) {
|
||||
setMarkingAsReadId(item.id);
|
||||
await markAsRead(item.id);
|
||||
setMarkingAsReadId(null);
|
||||
}
|
||||
|
||||
if (item.type === "new_mention") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isNewMentionMetadata(item.metadata)) {
|
||||
const searchSpaceId = item.search_space_id;
|
||||
const threadId = item.metadata.thread_id;
|
||||
const commentId = item.metadata.comment_id;
|
||||
|
||||
if (searchSpaceId && threadId) {
|
||||
const url = commentId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||
onOpenChange(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
await markAllAsRead();
|
||||
}, [markAllAsRead]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return "now";
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return `${Math.floor(diffDays / 7)}w`;
|
||||
} catch {
|
||||
return "now";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (item: InboxItem) => {
|
||||
// For mentions, show the author's avatar with initials fallback
|
||||
if (item.type === "new_mention") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isNewMentionMetadata(item.metadata)) {
|
||||
const authorName = item.metadata.author_name;
|
||||
const avatarUrl = item.metadata.author_avatar_url;
|
||||
const authorEmail = item.metadata.author_email;
|
||||
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(authorName, authorEmail)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
// Fallback for invalid metadata
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(null, null)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// For status items (connector/document), show status icons
|
||||
// Safely access status from metadata
|
||||
const metadata = item.metadata as Record<string, unknown>;
|
||||
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||
<Spinner size="sm" className="text-foreground" />
|
||||
</div>
|
||||
);
|
||||
case "completed":
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getEmptyStateMessage = () => {
|
||||
if (activeTab === "mentions") {
|
||||
return {
|
||||
title: t("no_mentions") || "No mentions",
|
||||
hint: t("no_mentions_hint") || "You'll see mentions from others here",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t("no_status_updates") || "No status updates",
|
||||
hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
|
||||
};
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-70 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("inbox") || "Inbox"}
|
||||
>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mobile: Button that opens bottom drawer */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setFilterDrawerOpen(true)}
|
||||
>
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Drawer
|
||||
open={filterDrawerOpen}
|
||||
onOpenChange={setFilterDrawerOpen}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<ListFilter className="size-5" />
|
||||
{t("filter") || "Filter"}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Filter section */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("filter") || "Filter"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFilter("all");
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
activeFilter === "all"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
<span>{t("all") || "All"}</span>
|
||||
</span>
|
||||
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFilter("unread");
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
activeFilter === "unread"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BellDot className="h-4 w-4" />
|
||||
<span>{t("unread") || "Unread"}</span>
|
||||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connectors section - only for status tab */}
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("connectors") || "Connectors"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(null);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === null
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<button
|
||||
key={connector.type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(connector.type);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === connector.type
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
/* Desktop: Dropdown menu */
|
||||
<DropdownMenu
|
||||
open={openDropdown === "filter"}
|
||||
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||
{t("filter") || "Filter"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActiveFilter("all")}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
<span>{t("all") || "All"}</span>
|
||||
</span>
|
||||
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActiveFilter("unread")}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BellDot className="h-4 w-4" />
|
||||
<span>{t("unread") || "Unread"}</span>
|
||||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||
{t("connectors") || "Connectors"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{t("mark_all_read") || "Mark all as read"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_inbox") || "Search inbox"}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-8 h-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="mentions"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<AtSign className="h-4 w-4" />
|
||||
<span>{t("mentions") || "Mentions"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{unreadMentionsCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="status"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<History className="h-4 w-4" />
|
||||
<span>{t("status") || "Status"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{unreadStatusCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only if not searching)
|
||||
const isPrefetchTrigger =
|
||||
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Time and unread dot - fixed width to prevent content shift */}
|
||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(item.created_at)}
|
||||
</span>
|
||||
{!item.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
</div>
|
||||
) : searchQuery ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_results_found") || "No results found"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
{activeTab === "mentions" ? (
|
||||
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
) : (
|
||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{getEmptyStateMessage().hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
type="button"
|
||||
onClick={() => onItemClick?.(item)}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
item.isActive && "bg-accent text-accent-foreground"
|
||||
|
|
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.badge && (
|
||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{item.title}</span>
|
||||
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
|
||||
{item.badge && (
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -119,11 +119,6 @@ export function Sidebar({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform navigation */}
|
||||
{navItems.length > 0 && (
|
||||
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
|
||||
)}
|
||||
|
||||
{/* Scrollable content */}
|
||||
<ScrollArea className="flex-1">
|
||||
{isCollapsed ? (
|
||||
|
|
@ -235,7 +230,12 @@ export function Sidebar({
|
|||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto">
|
||||
<div className="mt-auto border-t">
|
||||
{/* Platform navigation */}
|
||||
{navItems.length > 0 && (
|
||||
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
|
||||
)}
|
||||
|
||||
{pageUsage && !isCollapsed && (
|
||||
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronsUpDown, Settings, Users } from "lucide-react";
|
||||
import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -29,6 +30,9 @@ export function SidebarHeader({
|
|||
className,
|
||||
}: SidebarHeaderProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
|
||||
return (
|
||||
<div className={cn("flex shrink-0 items-center", className)}>
|
||||
|
|
@ -52,6 +56,10 @@ export function SidebarHeader({
|
|||
<Users className="mr-2 h-4 w-4" />
|
||||
{t("manage_members")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
|
||||
<ScrollText className="mr-2 h-4 w-4" />
|
||||
{t("logs")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function SidebarSection({
|
|||
|
||||
{/* Action button - visible on hover (always visible on mobile) */}
|
||||
{action && (
|
||||
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||
<div className="shrink-0 opacity-100 md:opacity-0 md:group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { InboxSidebar } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2, User, Users } from "lucide-react";
|
||||
import { User, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||
|
|
@ -45,7 +45,6 @@ const visibilityOptions: {
|
|||
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Use Jotai atom for visibility (single source of truth)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
|
@ -62,7 +61,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
// Update Jotai atom immediately for instant UI feedback
|
||||
setThreadVisibility(newVisibility);
|
||||
|
||||
|
|
@ -84,8 +82,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
// Revert Jotai state on error
|
||||
setThreadVisibility(thread.visibility ?? "PRIVATE");
|
||||
toast.error("Failed to update sharing settings");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
|
||||
|
|
@ -128,16 +124,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="p-1.5 space-y-1">
|
||||
{/* Updating overlay */}
|
||||
{isUpdating && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Updating</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibilityOptions.map((option) => {
|
||||
const isSelected = currentVisibility === option.value;
|
||||
const Icon = option.icon;
|
||||
|
|
@ -147,7 +133,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
type="button"
|
||||
key={option.value}
|
||||
onClick={() => handleVisibilityChange(option.value)}
|
||||
disabled={isUpdating}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||
"hover:bg-accent/50 cursor-pointer",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ interface ModelSelectorProps {
|
|||
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
|
||||
// Fetch configs
|
||||
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
|
|
@ -124,6 +123,11 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
);
|
||||
}, [userConfigs, searchQuery]);
|
||||
|
||||
// Total model count for conditional search display
|
||||
const totalModels = useMemo(() => {
|
||||
return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0);
|
||||
}, [globalConfigs, userConfigs]);
|
||||
|
||||
const handleSelectConfig = useCallback(
|
||||
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
|
||||
// If already selected, just close
|
||||
|
|
@ -137,7 +141,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
return;
|
||||
}
|
||||
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
|
|
@ -150,8 +153,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
} catch (error) {
|
||||
console.error("Failed to switch model:", error);
|
||||
toast.error("Failed to switch model");
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[currentConfig, searchSpaceId, updatePreferences]
|
||||
|
|
@ -216,26 +217,17 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
shouldFilter={false}
|
||||
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{/* Switching overlay */}
|
||||
{isSwitching && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Switching model...</span>
|
||||
</div>
|
||||
{totalModels > 3 && (
|
||||
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||
disabled={isSwitching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
|
|
@ -260,7 +252,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
value={`global-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
|
|
@ -291,7 +283,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleEditConfig(e, config, true)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
|
|
@ -322,7 +314,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
value={`user-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
|
|
@ -353,7 +345,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleEditConfig(e, config, false)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NotificationPopup } from "./NotificationPopup";
|
||||
|
||||
const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
|
||||
|
||||
export function NotificationButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const params = useParams();
|
||||
|
||||
// Filter state - null means show all, otherwise filter by type
|
||||
const [activeFilter, setActiveFilter] = useState<NotificationTypeEnum | null>(null);
|
||||
|
||||
// Load filter from localStorage on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (
|
||||
parsed === null ||
|
||||
["new_mention", "connector_indexing", "document_processing"].includes(parsed)
|
||||
) {
|
||||
setActiveFilter(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle filter toggle - clicking same pill again shows all
|
||||
const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
|
||||
setActiveFilter((current) => {
|
||||
const newFilter = current === filter ? null : filter;
|
||||
try {
|
||||
localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
return newFilter;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
|
||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||
|
||||
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
|
||||
userId,
|
||||
searchSpaceId,
|
||||
activeFilter
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 relative border-0">
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
|
||||
unreadCount > 9 && "px-1"
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Notifications</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<NotificationPopup
|
||||
notifications={notifications}
|
||||
unreadCount={unreadCount}
|
||||
loading={loading}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
onClose={() => setOpen(false)}
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
AlertCircle,
|
||||
AtSign,
|
||||
Bell,
|
||||
Cable,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Filter configuration for notification types
|
||||
*/
|
||||
const NOTIFICATION_FILTERS = {
|
||||
new_mention: { label: "Mentions", icon: AtSign },
|
||||
connector_indexing: { label: "Connectors", icon: Cable },
|
||||
document_processing: { label: "Documents", icon: FileText },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get initials from name or email for avatar fallback
|
||||
*/
|
||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
if (email) {
|
||||
const localPart = email.split("@")[0];
|
||||
return localPart.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return "U";
|
||||
}
|
||||
|
||||
interface NotificationPopupProps {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onClose?: () => void;
|
||||
activeFilter: NotificationTypeEnum | null;
|
||||
onFilterChange: (filter: NotificationTypeEnum | null) => void;
|
||||
}
|
||||
|
||||
export function NotificationPopup({
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onClose,
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
}: NotificationPopupProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
await markAllAsRead();
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.read) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.type === "new_mention") {
|
||||
const metadata = notification.metadata as {
|
||||
thread_id?: number;
|
||||
comment_id?: number;
|
||||
};
|
||||
const searchSpaceId = notification.search_space_id;
|
||||
const threadId = metadata?.thread_id;
|
||||
const commentId = metadata?.comment_id;
|
||||
|
||||
if (searchSpaceId && threadId) {
|
||||
const url = commentId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||
onClose?.();
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
|
||||
} catch {
|
||||
return "Recently";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (notification: Notification) => {
|
||||
// For mentions, show the author's avatar with initials fallback
|
||||
if (notification.type === "new_mention") {
|
||||
const metadata = notification.metadata as {
|
||||
author_name?: string;
|
||||
author_avatar_url?: string | null;
|
||||
author_email?: string;
|
||||
};
|
||||
const authorName = metadata?.author_name;
|
||||
const avatarUrl = metadata?.author_avatar_url;
|
||||
const authorEmail = metadata?.author_email;
|
||||
|
||||
return (
|
||||
<Avatar className="h-6 w-6">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(authorName, authorEmail)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// For other notification types, show status icons
|
||||
const status = notification.metadata?.status as string | undefined;
|
||||
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">Notifications</h3>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-0" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Pills */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 overflow-x-auto">
|
||||
{(
|
||||
Object.entries(NOTIFICATION_FILTERS) as [
|
||||
NotificationTypeEnum,
|
||||
(typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
|
||||
][]
|
||||
).map(([key, { label, icon: Icon }]) => {
|
||||
const isActive = activeFilter === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onFilterChange(key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
|
||||
"border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<ScrollArea className="h-[400px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-0 pb-2">
|
||||
{notifications.map((notification, index) => (
|
||||
<div key={notification.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
|
||||
!notification.read && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium break-all",
|
||||
!notification.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
|
||||
{convertRenderedToDisplay(notification.message)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(notification.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{index < notifications.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -551,7 +551,9 @@ export function LLMConfigForm({
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-xs sm:text-sm font-medium">Enable Citations</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm font-medium">
|
||||
Enable Citations
|
||||
</FormLabel>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Include [citation:id] references to source documents
|
||||
</FormDescription>
|
||||
|
|
|
|||
115
surfsense_web/components/ui/drawer.tsx
Normal file
115
surfsense_web/components/ui/drawer.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
|
||||
}
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
|
||||
overlayClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay className={overlayClassName} />
|
||||
<DrawerPrimitive.Content
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />;
|
||||
}
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
|
||||
}
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerHandle.displayName = "DrawerHandle";
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerHandle,
|
||||
};
|
||||
|
|
@ -42,13 +42,15 @@ function SheetContent({
|
|||
className,
|
||||
children,
|
||||
side = "right",
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
overlayClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetOverlay className={overlayClassName} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
|
|
|
|||
33
surfsense_web/components/ui/spinner.tsx
Normal file
33
surfsense_web/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
/** Whether to hide the track behind the spinner arc */
|
||||
hideTrack?: boolean;
|
||||
/** Additional classes to apply */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "h-3 w-3 border-[1.5px]",
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-8 w-8 border-[3px]",
|
||||
xl: "h-10 w-10 border-4",
|
||||
};
|
||||
|
||||
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
|
||||
return (
|
||||
<output
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
"block animate-spin rounded-full",
|
||||
hideTrack ? "border-transparent" : "border-current/20",
|
||||
"border-t-current",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
|
|||
|
||||
# Electric SQL
|
||||
|
||||
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
||||
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
||||
|
||||
## What Does Electric SQL Do?
|
||||
|
||||
When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
||||
When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
||||
|
||||
1. Backend writes data to PostgreSQL
|
||||
2. Electric SQL detects changes and streams them to the frontend
|
||||
|
|
@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t
|
|||
|
||||
This means:
|
||||
|
||||
- **Notifications appear instantly** - No need to refresh the page
|
||||
- **Inbox updates appear instantly** - No need to refresh the page
|
||||
- **Document indexing progress updates live** - Watch your documents get processed
|
||||
- **Connector status syncs automatically** - See when connectors finish syncing
|
||||
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Raw comment
|
||||
*/
|
||||
export const rawComment = z.object({
|
||||
id: z.number(),
|
||||
message_id: z.number(),
|
||||
thread_id: z.number(), // Denormalized for efficient Electric subscriptions
|
||||
parent_id: z.number().nullable(),
|
||||
author_id: z.string().nullable(),
|
||||
content: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
|
||||
export const author = z.object({
|
||||
id: z.string().uuid(),
|
||||
display_name: z.string().nullable(),
|
||||
|
|
@ -122,6 +136,7 @@ export const getMentionsResponse = z.object({
|
|||
total_count: z.number(),
|
||||
});
|
||||
|
||||
export type RawComment = z.infer<typeof rawComment>;
|
||||
export type Author = z.infer<typeof author>;
|
||||
export type CommentReply = z.infer<typeof commentReply>;
|
||||
export type Comment = z.infer<typeof comment>;
|
||||
|
|
|
|||
15
surfsense_web/contracts/types/chat-messages.types.ts
Normal file
15
surfsense_web/contracts/types/chat-messages.types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Raw message from database (Electric SQL sync)
|
||||
*/
|
||||
export const rawMessage = z.object({
|
||||
id: z.number(),
|
||||
thread_id: z.number(),
|
||||
role: z.string(),
|
||||
content: z.unknown(),
|
||||
author_id: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export type RawMessage = z.infer<typeof rawMessage>;
|
||||
24
surfsense_web/contracts/types/chat-session-state.types.ts
Normal file
24
surfsense_web/contracts/types/chat-session-state.types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Chat session state for live collaboration.
|
||||
* Tracks which user the AI is currently responding to.
|
||||
*/
|
||||
export const chatSessionState = z.object({
|
||||
id: z.number(),
|
||||
thread_id: z.number(),
|
||||
ai_responding_to_user_id: z.string().uuid().nullable(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* User currently being responded to by the AI.
|
||||
*/
|
||||
export const respondingUser = z.object({
|
||||
id: z.string().uuid(),
|
||||
display_name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export type ChatSessionState = z.infer<typeof chatSessionState>;
|
||||
export type RespondingUser = z.infer<typeof respondingUser>;
|
||||
281
surfsense_web/contracts/types/inbox.types.ts
Normal file
281
surfsense_web/contracts/types/inbox.types.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { z } from "zod";
|
||||
import { searchSourceConnectorTypeEnum } from "./connector.types";
|
||||
import { documentTypeEnum } from "./document.types";
|
||||
|
||||
/**
|
||||
* Inbox item type enum - matches backend notification types
|
||||
*/
|
||||
export const inboxItemTypeEnum = z.enum([
|
||||
"connector_indexing",
|
||||
"document_processing",
|
||||
"new_mention",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Inbox item status enum - used in metadata
|
||||
*/
|
||||
export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
|
||||
|
||||
/**
|
||||
* Document processing stage enum
|
||||
*/
|
||||
export const documentProcessingStageEnum = z.enum([
|
||||
"queued",
|
||||
"parsing",
|
||||
"chunking",
|
||||
"embedding",
|
||||
"storing",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base metadata schema shared across inbox item types
|
||||
*/
|
||||
export const baseInboxItemMetadata = z.object({
|
||||
operation_id: z.string().optional(),
|
||||
status: inboxItemStatusEnum.optional(),
|
||||
started_at: z.string().optional(),
|
||||
completed_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Connector indexing metadata schema
|
||||
*/
|
||||
export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
|
||||
connector_id: z.number(),
|
||||
connector_name: z.string(),
|
||||
connector_type: searchSourceConnectorTypeEnum,
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
indexed_count: z.number(),
|
||||
total_count: z.number().optional(),
|
||||
progress_percent: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
// Google Drive specific fields
|
||||
folder_count: z.number().optional(),
|
||||
file_count: z.number().optional(),
|
||||
folder_names: z.array(z.string()).optional(),
|
||||
file_names: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Document processing metadata schema
|
||||
*/
|
||||
export const documentProcessingMetadata = baseInboxItemMetadata.extend({
|
||||
document_type: documentTypeEnum,
|
||||
document_name: z.string(),
|
||||
processing_stage: documentProcessingStageEnum,
|
||||
file_size: z.number().optional(),
|
||||
chunks_count: z.number().optional(),
|
||||
document_id: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* New mention metadata schema
|
||||
*/
|
||||
export const newMentionMetadata = z.object({
|
||||
mention_id: z.number(),
|
||||
comment_id: z.number(),
|
||||
message_id: z.number(),
|
||||
thread_id: z.number(),
|
||||
thread_title: z.string(),
|
||||
author_id: z.string(),
|
||||
author_name: z.string(),
|
||||
author_avatar_url: z.string().nullable().optional(),
|
||||
author_email: z.string().optional(),
|
||||
content_preview: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Union of all inbox item metadata types
|
||||
* Use this when the inbox item type is unknown
|
||||
*/
|
||||
export const inboxItemMetadata = z.union([
|
||||
connectorIndexingMetadata,
|
||||
documentProcessingMetadata,
|
||||
newMentionMetadata,
|
||||
baseInboxItemMetadata,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Main inbox item schema
|
||||
*/
|
||||
export const inboxItem = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.string(),
|
||||
search_space_id: z.number().nullable(),
|
||||
type: inboxItemTypeEnum,
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
read: z.boolean(),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Typed inbox item schemas for specific types
|
||||
*/
|
||||
export const connectorIndexingInboxItem = inboxItem.extend({
|
||||
type: z.literal("connector_indexing"),
|
||||
metadata: connectorIndexingMetadata,
|
||||
});
|
||||
|
||||
export const documentProcessingInboxItem = inboxItem.extend({
|
||||
type: z.literal("document_processing"),
|
||||
metadata: documentProcessingMetadata,
|
||||
});
|
||||
|
||||
export const newMentionInboxItem = inboxItem.extend({
|
||||
type: z.literal("new_mention"),
|
||||
metadata: newMentionMetadata,
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// API Request/Response Schemas
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Request schema for getting notifications
|
||||
*/
|
||||
export const getNotificationsRequest = z.object({
|
||||
queryParams: z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
type: inboxItemTypeEnum.optional(),
|
||||
before_date: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
offset: z.number().min(0).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for listing notifications
|
||||
*/
|
||||
export const getNotificationsResponse = z.object({
|
||||
items: z.array(inboxItem),
|
||||
total: z.number(),
|
||||
has_more: z.boolean(),
|
||||
next_offset: z.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Request schema for marking a single notification as read
|
||||
*/
|
||||
export const markNotificationReadRequest = z.object({
|
||||
notificationId: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for mark as read operations
|
||||
*/
|
||||
export const markNotificationReadResponse = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for mark all as read operation
|
||||
*/
|
||||
export const markAllNotificationsReadResponse = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
updated_count: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Request schema for getting unread count
|
||||
*/
|
||||
export const getUnreadCountRequest = z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for unread count
|
||||
* Returns both total and recent counts for split tracking
|
||||
*/
|
||||
export const getUnreadCountResponse = z.object({
|
||||
total_unread: z.number(),
|
||||
recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Type Guards for Metadata
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type guard for ConnectorIndexingMetadata
|
||||
*/
|
||||
export function isConnectorIndexingMetadata(
|
||||
metadata: unknown
|
||||
): metadata is ConnectorIndexingMetadata {
|
||||
return connectorIndexingMetadata.safeParse(metadata).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for DocumentProcessingMetadata
|
||||
*/
|
||||
export function isDocumentProcessingMetadata(
|
||||
metadata: unknown
|
||||
): metadata is DocumentProcessingMetadata {
|
||||
return documentProcessingMetadata.safeParse(metadata).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for NewMentionMetadata
|
||||
*/
|
||||
export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata {
|
||||
return newMentionMetadata.safeParse(metadata).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe metadata parser - returns typed metadata or null
|
||||
*/
|
||||
export function parseInboxItemMetadata(
|
||||
type: InboxItemTypeEnum,
|
||||
metadata: unknown
|
||||
): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null {
|
||||
switch (type) {
|
||||
case "connector_indexing": {
|
||||
const result = connectorIndexingMetadata.safeParse(metadata);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
case "document_processing": {
|
||||
const result = documentProcessingMetadata.safeParse(metadata);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
case "new_mention": {
|
||||
const result = newMentionMetadata.safeParse(metadata);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Inferred types
|
||||
// =============================================================================
|
||||
|
||||
export type InboxItemTypeEnum = z.infer<typeof inboxItemTypeEnum>;
|
||||
export type InboxItemStatusEnum = z.infer<typeof inboxItemStatusEnum>;
|
||||
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
|
||||
export type BaseInboxItemMetadata = z.infer<typeof baseInboxItemMetadata>;
|
||||
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
|
||||
export type InboxItem = z.infer<typeof inboxItem>;
|
||||
export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
|
||||
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
||||
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
||||
|
||||
// API Request/Response types
|
||||
export type GetNotificationsRequest = z.infer<typeof getNotificationsRequest>;
|
||||
export type GetNotificationsResponse = z.infer<typeof getNotificationsResponse>;
|
||||
export type MarkNotificationReadRequest = z.infer<typeof markNotificationReadRequest>;
|
||||
export type MarkNotificationReadResponse = z.infer<typeof markNotificationReadResponse>;
|
||||
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
|
||||
export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
|
||||
export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>;
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import { searchSourceConnectorTypeEnum } from "./connector.types";
|
||||
import { documentTypeEnum } from "./document.types";
|
||||
|
||||
/**
|
||||
* Notification type enum - matches backend notification types
|
||||
*/
|
||||
export const notificationTypeEnum = z.enum([
|
||||
"connector_indexing",
|
||||
"document_processing",
|
||||
"new_mention",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Notification status enum - used in metadata
|
||||
*/
|
||||
export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
|
||||
|
||||
/**
|
||||
* Document processing stage enum
|
||||
*/
|
||||
export const documentProcessingStageEnum = z.enum([
|
||||
"queued",
|
||||
"parsing",
|
||||
"chunking",
|
||||
"embedding",
|
||||
"storing",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base metadata schema shared across notification types
|
||||
*/
|
||||
export const baseNotificationMetadata = z.object({
|
||||
operation_id: z.string().optional(),
|
||||
status: notificationStatusEnum.optional(),
|
||||
started_at: z.string().optional(),
|
||||
completed_at: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Connector indexing metadata schema
|
||||
*/
|
||||
export const connectorIndexingMetadata = baseNotificationMetadata.extend({
|
||||
connector_id: z.number(),
|
||||
connector_name: z.string(),
|
||||
connector_type: searchSourceConnectorTypeEnum,
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
indexed_count: z.number(),
|
||||
total_count: z.number().optional(),
|
||||
progress_percent: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
// Google Drive specific fields
|
||||
folder_count: z.number().optional(),
|
||||
file_count: z.number().optional(),
|
||||
folder_names: z.array(z.string()).optional(),
|
||||
file_names: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Document processing metadata schema
|
||||
*/
|
||||
export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
||||
document_type: documentTypeEnum,
|
||||
document_name: z.string(),
|
||||
processing_stage: documentProcessingStageEnum,
|
||||
file_size: z.number().optional(),
|
||||
chunks_count: z.number().optional(),
|
||||
document_id: z.number().optional(),
|
||||
error_message: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* New mention metadata schema
|
||||
*/
|
||||
export const newMentionMetadata = z.object({
|
||||
mention_id: z.number(),
|
||||
comment_id: z.number(),
|
||||
message_id: z.number(),
|
||||
thread_id: z.number(),
|
||||
thread_title: z.string(),
|
||||
author_id: z.string(),
|
||||
author_name: z.string(),
|
||||
author_avatar_url: z.string().nullable().optional(),
|
||||
author_email: z.string().optional(),
|
||||
content_preview: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Union of all notification metadata types
|
||||
* Use this when the notification type is unknown
|
||||
*/
|
||||
export const notificationMetadata = z.union([
|
||||
connectorIndexingMetadata,
|
||||
documentProcessingMetadata,
|
||||
newMentionMetadata,
|
||||
baseNotificationMetadata,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Main notification schema
|
||||
*/
|
||||
export const notification = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.string(),
|
||||
search_space_id: z.number().nullable(),
|
||||
type: notificationTypeEnum,
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
read: z.boolean(),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Typed notification schemas for specific notification types
|
||||
*/
|
||||
export const connectorIndexingNotification = notification.extend({
|
||||
type: z.literal("connector_indexing"),
|
||||
metadata: connectorIndexingMetadata,
|
||||
});
|
||||
|
||||
export const documentProcessingNotification = notification.extend({
|
||||
type: z.literal("document_processing"),
|
||||
metadata: documentProcessingMetadata,
|
||||
});
|
||||
|
||||
export const newMentionNotification = notification.extend({
|
||||
type: z.literal("new_mention"),
|
||||
metadata: newMentionMetadata,
|
||||
});
|
||||
|
||||
// Inferred types
|
||||
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
|
||||
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
|
||||
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
|
||||
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
|
||||
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
|
||||
export type Notification = z.infer<typeof notification>;
|
||||
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
|
||||
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
|
||||
export type NewMentionNotification = z.infer<typeof newMentionNotification>;
|
||||
39
surfsense_web/hooks/use-chat-session-state.ts
Normal file
39
surfsense_web/hooks/use-chat-session-state.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useShape } from "@electric-sql/react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import type { ChatSessionState } from "@/contracts/types/chat-session-state.types";
|
||||
|
||||
const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133";
|
||||
|
||||
/**
|
||||
* Syncs chat session state for a thread via Electric SQL.
|
||||
* Call once per thread (in page.tsx). Updates global atom.
|
||||
*/
|
||||
export function useChatSessionStateSync(threadId: number | null) {
|
||||
const setSessionState = useSetAtom(chatSessionStateAtom);
|
||||
|
||||
const { data } = useShape<ChatSessionState>({
|
||||
url: `${ELECTRIC_URL}/v1/shape`,
|
||||
params: {
|
||||
table: "chat_session_state",
|
||||
where: `thread_id = ${threadId ?? -1}`,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId) {
|
||||
setSessionState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = data?.[0];
|
||||
setSessionState({
|
||||
threadId,
|
||||
isAiResponding: !!row?.ai_responding_to_user_id,
|
||||
respondingToUserId: row?.ai_responding_to_user_id ?? null,
|
||||
});
|
||||
}, [threadId, data, setSessionState]);
|
||||
}
|
||||
405
surfsense_web/hooks/use-comments-electric.ts
Normal file
405
surfsense_web/hooks/use-comments-electric.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import type { Author, Comment, CommentReply } from "@/contracts/types/chat-comments.types";
|
||||
import type { Membership } from "@/contracts/types/members.types";
|
||||
import type { SyncHandle } from "@/lib/electric/client";
|
||||
import { useElectricClient } from "@/lib/electric/context";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
// Debounce delay for stream updates (ms)
|
||||
const STREAM_UPDATE_DEBOUNCE_MS = 100;
|
||||
|
||||
// Raw comment from PGlite local database
|
||||
interface RawCommentRow {
|
||||
id: number;
|
||||
message_id: number;
|
||||
thread_id: number;
|
||||
parent_id: number | null;
|
||||
author_id: string | null;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN)
|
||||
const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g;
|
||||
|
||||
type MemberInfo = Pick<Membership, "user_display_name" | "user_avatar_url" | "user_email">;
|
||||
|
||||
/**
|
||||
* Render mentions in content by replacing @[uuid] with @{DisplayName}
|
||||
*/
|
||||
function renderMentions(content: string, memberMap: Map<string, MemberInfo>): string {
|
||||
return content.replace(MENTION_PATTERN, (match, uuid) => {
|
||||
const member = memberMap.get(uuid);
|
||||
if (member?.user_display_name) {
|
||||
return `@{${member.user_display_name}}`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build member lookup map from membersData
|
||||
*/
|
||||
function buildMemberMap(membersData: Membership[] | undefined): Map<string, MemberInfo> {
|
||||
const map = new Map<string, MemberInfo>();
|
||||
if (membersData) {
|
||||
for (const m of membersData) {
|
||||
map.set(m.user_id, {
|
||||
user_display_name: m.user_display_name,
|
||||
user_avatar_url: m.user_avatar_url,
|
||||
user_email: m.user_email,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build author object from member data
|
||||
*/
|
||||
function buildAuthor(authorId: string | null, memberMap: Map<string, MemberInfo>): Author | null {
|
||||
if (!authorId) return null;
|
||||
const m = memberMap.get(authorId);
|
||||
if (!m) return null;
|
||||
return {
|
||||
id: authorId,
|
||||
display_name: m.user_display_name ?? null,
|
||||
avatar_url: m.user_avatar_url ?? null,
|
||||
email: m.user_email ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment has been edited by comparing timestamps.
|
||||
* Uses a small threshold to handle precision differences.
|
||||
*/
|
||||
function isEdited(createdAt: string, updatedAt: string): boolean {
|
||||
const created = new Date(createdAt).getTime();
|
||||
const updated = new Date(updatedAt).getTime();
|
||||
// Consider edited if updated_at is more than 1 second after created_at
|
||||
return updated - created > 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform raw comment to CommentReply
|
||||
*/
|
||||
function transformReply(
|
||||
raw: RawCommentRow,
|
||||
memberMap: Map<string, MemberInfo>,
|
||||
currentUserId: string | undefined,
|
||||
isOwner: boolean
|
||||
): CommentReply {
|
||||
return {
|
||||
id: raw.id,
|
||||
content: raw.content,
|
||||
content_rendered: renderMentions(raw.content, memberMap),
|
||||
author: buildAuthor(raw.author_id, memberMap),
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
is_edited: isEdited(raw.created_at, raw.updated_at),
|
||||
can_edit: currentUserId === raw.author_id,
|
||||
can_delete: currentUserId === raw.author_id || isOwner,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform raw comments to Comment with replies
|
||||
*/
|
||||
function transformComments(
|
||||
rawComments: RawCommentRow[],
|
||||
memberMap: Map<string, MemberInfo>,
|
||||
currentUserId: string | undefined,
|
||||
isOwner: boolean
|
||||
): Map<number, Comment[]> {
|
||||
// Group comments by message_id
|
||||
const byMessage = new Map<
|
||||
number,
|
||||
{ topLevel: RawCommentRow[]; replies: Map<number, RawCommentRow[]> }
|
||||
>();
|
||||
|
||||
for (const raw of rawComments) {
|
||||
if (!byMessage.has(raw.message_id)) {
|
||||
byMessage.set(raw.message_id, { topLevel: [], replies: new Map() });
|
||||
}
|
||||
const group = byMessage.get(raw.message_id)!;
|
||||
|
||||
if (raw.parent_id === null) {
|
||||
group.topLevel.push(raw);
|
||||
} else {
|
||||
if (!group.replies.has(raw.parent_id)) {
|
||||
group.replies.set(raw.parent_id, []);
|
||||
}
|
||||
group.replies.get(raw.parent_id)!.push(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform to Comment objects grouped by message_id
|
||||
const result = new Map<number, Comment[]>();
|
||||
|
||||
for (const [messageId, group] of byMessage) {
|
||||
const comments: Comment[] = group.topLevel.map((raw) => {
|
||||
const replies = (group.replies.get(raw.id) || [])
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
.map((r) => transformReply(r, memberMap, currentUserId, isOwner));
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
message_id: raw.message_id,
|
||||
content: raw.content,
|
||||
content_rendered: renderMentions(raw.content, memberMap),
|
||||
author: buildAuthor(raw.author_id, memberMap),
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
is_edited: isEdited(raw.created_at, raw.updated_at),
|
||||
can_edit: currentUserId === raw.author_id,
|
||||
can_delete: currentUserId === raw.author_id || isOwner,
|
||||
reply_count: replies.length,
|
||||
replies,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by created_at
|
||||
comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
result.set(messageId, comments);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for syncing comments with Electric SQL real-time sync.
|
||||
*
|
||||
* Syncs ALL comments for a thread in ONE subscription, then updates
|
||||
* React Query cache for each message. This avoids N subscriptions for N messages.
|
||||
*
|
||||
* @param threadId - The thread ID to sync comments for
|
||||
*/
|
||||
export function useCommentsElectric(threadId: number | null) {
|
||||
const electricClient = useElectricClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: membersData } = useAtomValue(membersAtom);
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: myAccess } = useAtomValue(myAccessAtom);
|
||||
|
||||
const memberMap = useMemo(() => buildMemberMap(membersData), [membersData]);
|
||||
const currentUserId = currentUser?.id;
|
||||
const isOwner = myAccess?.is_owner ?? false;
|
||||
|
||||
// Use refs for values needed in live query callback to avoid stale closures
|
||||
const memberMapRef = useRef(memberMap);
|
||||
const currentUserIdRef = useRef(currentUserId);
|
||||
const isOwnerRef = useRef(isOwner);
|
||||
const queryClientRef = useRef(queryClient);
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => {
|
||||
memberMapRef.current = memberMap;
|
||||
currentUserIdRef.current = currentUserId;
|
||||
isOwnerRef.current = isOwner;
|
||||
queryClientRef.current = queryClient;
|
||||
}, [memberMap, currentUserId, isOwner, queryClient]);
|
||||
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const syncKeyRef = useRef<string | null>(null);
|
||||
const streamUpdateDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Stable callback that uses refs for fresh values
|
||||
const updateReactQueryCache = useCallback((rows: RawCommentRow[]) => {
|
||||
const commentsByMessage = transformComments(
|
||||
rows,
|
||||
memberMapRef.current,
|
||||
currentUserIdRef.current,
|
||||
isOwnerRef.current
|
||||
);
|
||||
|
||||
for (const [messageId, comments] of commentsByMessage) {
|
||||
const cacheKey = cacheKeys.comments.byMessage(messageId);
|
||||
queryClientRef.current.setQueryData(cacheKey, {
|
||||
comments,
|
||||
total_count: comments.length,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId || !electricClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncKey = `comments_${threadId}`;
|
||||
if (syncKeyRef.current === syncKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture in local variable for use in async functions
|
||||
const client = electricClient;
|
||||
|
||||
let mounted = true;
|
||||
syncKeyRef.current = syncKey;
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const handle = await client.syncShape({
|
||||
table: "chat_comments",
|
||||
where: `thread_id = ${threadId}`,
|
||||
columns: [
|
||||
"id",
|
||||
"message_id",
|
||||
"thread_id",
|
||||
"parent_id",
|
||||
"author_id",
|
||||
"content",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
primaryKey: ["id"],
|
||||
});
|
||||
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
try {
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
} catch {
|
||||
// Initial sync timeout - continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
handle.unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
|
||||
// Fetch initial comments and update cache
|
||||
await fetchAndUpdateCache();
|
||||
|
||||
// Set up live query for real-time updates
|
||||
await setupLiveQuery();
|
||||
|
||||
// Subscribe to the sync stream for real-time updates from Electric SQL
|
||||
// This ensures we catch updates even if PGlite live query misses them
|
||||
if (handle.stream) {
|
||||
const stream = handle.stream as {
|
||||
subscribe?: (callback: (messages: unknown[]) => void) => void;
|
||||
};
|
||||
if (typeof stream.subscribe === "function") {
|
||||
stream.subscribe((messages: unknown[]) => {
|
||||
if (!mounted) return;
|
||||
// When Electric sync receives new data, refresh from PGlite
|
||||
// This handles cases where live query might miss the update
|
||||
if (messages && messages.length > 0) {
|
||||
// Debounce the refresh to avoid excessive queries
|
||||
if (streamUpdateDebounceRef.current) {
|
||||
clearTimeout(streamUpdateDebounceRef.current);
|
||||
}
|
||||
streamUpdateDebounceRef.current = setTimeout(() => {
|
||||
if (mounted) {
|
||||
fetchAndUpdateCache();
|
||||
}
|
||||
}, STREAM_UPDATE_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Sync failed - will retry on next mount
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndUpdateCache() {
|
||||
try {
|
||||
const result = await client.db.query<RawCommentRow>(
|
||||
`SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at
|
||||
FROM chat_comments
|
||||
WHERE thread_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[threadId]
|
||||
);
|
||||
|
||||
if (mounted && result.rows) {
|
||||
updateReactQueryCache(result.rows);
|
||||
}
|
||||
} catch {
|
||||
// Query failed - data will be fetched from API
|
||||
}
|
||||
}
|
||||
|
||||
async function setupLiveQuery() {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = client.db as any;
|
||||
|
||||
if (db.live?.query && typeof db.live.query === "function") {
|
||||
const liveQuery = await db.live.query(
|
||||
`SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at
|
||||
FROM chat_comments
|
||||
WHERE thread_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[threadId]
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial results
|
||||
if (liveQuery.initialResults?.rows) {
|
||||
updateReactQueryCache(liveQuery.initialResults.rows);
|
||||
} else if (liveQuery.rows) {
|
||||
updateReactQueryCache(liveQuery.rows);
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
if (typeof liveQuery.subscribe === "function") {
|
||||
liveQuery.subscribe((result: { rows: RawCommentRow[] }) => {
|
||||
if (mounted && result.rows) {
|
||||
updateReactQueryCache(result.rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof liveQuery.unsubscribe === "function") {
|
||||
liveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Live query setup failed - will use initial fetch only
|
||||
}
|
||||
}
|
||||
|
||||
startSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
syncKeyRef.current = null;
|
||||
|
||||
// Clear debounce timeout
|
||||
if (streamUpdateDebounceRef.current) {
|
||||
clearTimeout(streamUpdateDebounceRef.current);
|
||||
streamUpdateDebounceRef.current = null;
|
||||
}
|
||||
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [threadId, electricClient, updateReactQueryCache]);
|
||||
}
|
||||
523
surfsense_web/hooks/use-inbox.ts
Normal file
523
surfsense_web/hooks/use-inbox.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import type { SyncHandle } from "@/lib/electric/client";
|
||||
import { useElectricClient } from "@/lib/electric/context";
|
||||
|
||||
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SYNC_WINDOW_DAYS = 14;
|
||||
|
||||
/**
|
||||
* Check if an item is older than the sync window
|
||||
*/
|
||||
function isOlderThanSyncWindow(createdAt: string): boolean {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS);
|
||||
return new Date(createdAt) < cutoffDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate by ID and sort by created_at descending.
|
||||
* This is the SINGLE source of truth for deduplication - prevents race conditions.
|
||||
*/
|
||||
function deduplicateAndSort(items: InboxItem[]): InboxItem[] {
|
||||
const seen = new Map<number, InboxItem>();
|
||||
for (const item of items) {
|
||||
if (!seen.has(item.id)) {
|
||||
seen.set(item.id, item);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values()).sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the cutoff date for sync window
|
||||
*/
|
||||
function getSyncCutoffDate(): string {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
|
||||
return cutoff.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a date value to ISO string format
|
||||
*/
|
||||
function toISOString(date: string | Date | null | undefined): string | null {
|
||||
if (!date) return null;
|
||||
if (date instanceof Date) return date.toISOString();
|
||||
if (typeof date === "string") {
|
||||
if (date.includes("T")) return date;
|
||||
try {
|
||||
return new Date(date).toISOString();
|
||||
} catch {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing inbox items with Electric SQL real-time sync + API fallback
|
||||
*
|
||||
* Architecture (Simplified & Race-Condition Free):
|
||||
* - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates
|
||||
* - Live Query: Provides reactive first page from PGLite
|
||||
* - API: Handles all pagination (more reliable than mixing with Electric)
|
||||
*
|
||||
* Key Design Decisions:
|
||||
* 1. No mutable refs for cursor - cursor computed from current state
|
||||
* 2. Single deduplicateAndSort function - prevents inconsistencies
|
||||
* 3. Filter-based preservation in live query - prevents data loss
|
||||
* 4. Auto-fetch from API when Electric returns 0 items
|
||||
*
|
||||
* @param userId - The user ID to fetch inbox items for
|
||||
* @param searchSpaceId - The search space ID to filter inbox items
|
||||
* @param typeFilter - Optional inbox item type to filter by
|
||||
*/
|
||||
export function useInbox(
|
||||
userId: string | null,
|
||||
searchSpaceId: number | null,
|
||||
typeFilter: InboxItemTypeEnum | null = null
|
||||
) {
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Split unread count tracking for accurate counts with 14-day sync window
|
||||
// olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation)
|
||||
// recentUnreadCount = unread items within sync window (from live query, real-time)
|
||||
const [olderUnreadCount, setOlderUnreadCount] = useState(0);
|
||||
const [recentUnreadCount, setRecentUnreadCount] = useState(0);
|
||||
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const userSyncKeyRef = useRef<string | null>(null);
|
||||
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
|
||||
// Total unread = older (static from server) + recent (live from Electric)
|
||||
const totalUnreadCount = olderUnreadCount + recentUnreadCount;
|
||||
|
||||
// EFFECT 1: Electric SQL sync for real-time updates
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
setLoading(!electricClient);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const cutoffDate = getSyncCutoffDate();
|
||||
const userSyncKey = `inbox_${userId}_${cutoffDate}`;
|
||||
|
||||
// Skip if already syncing with this key
|
||||
if (userSyncKeyRef.current === userSyncKey) return;
|
||||
|
||||
// Clean up previous sync
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
|
||||
console.log("[useInbox] Starting sync for:", userId);
|
||||
userSyncKeyRef.current = userSyncKey;
|
||||
|
||||
const handle = await client.syncShape({
|
||||
table: "notifications",
|
||||
where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
|
||||
primaryKey: ["id"],
|
||||
});
|
||||
|
||||
// Wait for initial sync with timeout
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
handle.unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error("[useInbox] Sync failed:", err);
|
||||
setError(err instanceof Error ? err : new Error("Sync failed"));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
startSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
userSyncKeyRef.current = null;
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, electricClient]);
|
||||
|
||||
// Reset when filters change
|
||||
useEffect(() => {
|
||||
setHasMore(true);
|
||||
setInboxItems([]);
|
||||
// Reset count states - will be refetched by the unread count effect
|
||||
setOlderUnreadCount(0);
|
||||
setRecentUnreadCount(0);
|
||||
}, [userId, searchSpaceId, typeFilter]);
|
||||
|
||||
// EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) return;
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
|
||||
async function setupLiveQuery() {
|
||||
// Clean up previous live query
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const cutoff = getSyncCutoffDate();
|
||||
|
||||
const query = `SELECT * FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||
AND created_at > '${cutoff}'
|
||||
${typeFilter ? "AND type = $3" : ""}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${PAGE_SIZE}`;
|
||||
|
||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||
|
||||
const db = client.db as any;
|
||||
|
||||
// Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync
|
||||
const result = await client.db.query<InboxItem>(query, params);
|
||||
|
||||
if (mounted && result.rows) {
|
||||
const items = deduplicateAndSort(result.rows);
|
||||
setInboxItems(items);
|
||||
|
||||
// AUTO-FETCH: If Electric returned 0 items, check API for older items
|
||||
// This handles the edge case where user has no recent notifications
|
||||
// but has older ones outside the sync window
|
||||
if (items.length === 0) {
|
||||
console.log(
|
||||
"[useInbox] Electric returned 0 items, checking API for older notifications"
|
||||
);
|
||||
try {
|
||||
// Use the API service with proper Zod validation for API responses
|
||||
const data = await notificationsApiService.getNotifications({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ?? undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
if (data.items.length > 0) {
|
||||
setInboxItems(data.items);
|
||||
}
|
||||
setHasMore(data.has_more);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useInbox] API fallback failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up live query for real-time updates
|
||||
if (db.live?.query) {
|
||||
const liveQuery = await db.live.query(query, params);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (liveQuery.subscribe) {
|
||||
// Live query data comes from PGlite - no validation needed
|
||||
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
||||
if (mounted && result.rows) {
|
||||
const liveItems = result.rows;
|
||||
|
||||
setInboxItems((prev) => {
|
||||
const liveItemIds = new Set(liveItems.map((item) => item.id));
|
||||
|
||||
// FIXED: Keep ALL items not in live result (not just slice)
|
||||
// This prevents data loss when new notifications push items
|
||||
// out of the LIMIT window
|
||||
const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
|
||||
|
||||
return deduplicateAndSort([...liveItems, ...itemsToKeep]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (liveQuery.unsubscribe) {
|
||||
liveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useInbox] Live query error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
setupLiveQuery();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
|
||||
// EFFECT 3: Dedicated unread count sync with split tracking
|
||||
// - Fetches server count on mount (accurate total)
|
||||
// - Sets up live query for recent count (real-time updates)
|
||||
// - Handles items older than sync window separately
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) return;
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
|
||||
async function setupUnreadCountSync() {
|
||||
// Cleanup previous live query
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
|
||||
console.log("[useInbox] Fetching unread count from server");
|
||||
const serverCounts = await notificationsApiService.getUnreadCount(
|
||||
searchSpaceId ?? undefined
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
// Calculate older count = total - recent
|
||||
const olderCount = serverCounts.total_unread - serverCounts.recent_unread;
|
||||
setOlderUnreadCount(olderCount);
|
||||
setRecentUnreadCount(serverCounts.recent_unread);
|
||||
console.log(
|
||||
`[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}`
|
||||
);
|
||||
}
|
||||
|
||||
// STEP 2: Set up PGLite live query for RECENT unread count only
|
||||
// This provides real-time updates for notifications within sync window
|
||||
const db = client.db as any;
|
||||
const cutoff = getSyncCutoffDate();
|
||||
|
||||
// Count query - NO LIMIT, counts all unread in synced window
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as count FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||
AND created_at > '${cutoff}'
|
||||
AND read = false
|
||||
${typeFilter ? "AND type = $3" : ""}
|
||||
`;
|
||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||
|
||||
if (db.live?.query) {
|
||||
const liveQuery = await db.live.query(countQuery, params);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (liveQuery.subscribe) {
|
||||
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
|
||||
if (mounted && result.rows?.[0]) {
|
||||
const liveCount = Number(result.rows[0].count) || 0;
|
||||
// Update recent count from live query
|
||||
// This fires in real-time when Electric syncs new/updated notifications
|
||||
setRecentUnreadCount(liveCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (liveQuery.unsubscribe) {
|
||||
unreadCountLiveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useInbox] Unread count sync error:", err);
|
||||
// On error, counts will remain at 0 or previous values
|
||||
// The items-based count will be the fallback
|
||||
}
|
||||
}
|
||||
|
||||
setupUnreadCountSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
|
||||
// loadMore - Pure cursor-based pagination, no race conditions
|
||||
// Cursor is computed from current state, not stored in refs
|
||||
const loadMore = useCallback(async () => {
|
||||
// Removed inboxItems.length === 0 check to allow loading older items
|
||||
// when Electric returns 0 items
|
||||
if (!userId || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
// Cursor is computed from current state - no stale refs possible
|
||||
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
|
||||
const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null;
|
||||
|
||||
console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
|
||||
|
||||
// Use the API service with proper Zod validation
|
||||
const data = await notificationsApiService.getNotifications({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ?? undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
before_date: beforeDate ?? undefined,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.items.length > 0) {
|
||||
// Functional update ensures we always merge with latest state
|
||||
// Items are already validated by the API service
|
||||
setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
|
||||
}
|
||||
|
||||
// Use API's has_more flag
|
||||
setHasMore(data.has_more);
|
||||
} catch (err) {
|
||||
console.error("[useInbox] Load more failed:", err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
|
||||
|
||||
// Mark inbox item as read with optimistic update
|
||||
// Handles both recent items (live query updates count) and older items (manual count decrement)
|
||||
const markAsRead = useCallback(
|
||||
async (itemId: number) => {
|
||||
// Find the item to check if it's older than sync window
|
||||
const item = inboxItems.find((i) => i.id === itemId);
|
||||
const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at);
|
||||
|
||||
// Optimistic update: mark as read immediately for instant UI feedback
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
|
||||
|
||||
// If older item, manually decrement older count
|
||||
// (live query won't see items outside sync window)
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the API service with proper Zod validation
|
||||
const result = await notificationsApiService.markAsRead({ notificationId: itemId });
|
||||
|
||||
if (!result.success) {
|
||||
// Rollback on error
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
// If successful, Electric SQL will sync the change and live query will update
|
||||
// This ensures eventual consistency even if optimistic update was wrong
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark as read:", err);
|
||||
// Rollback on error
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => prev + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[inboxItems]
|
||||
);
|
||||
|
||||
// Mark all inbox items as read with optimistic update
|
||||
// Resets both older and recent counts to 0
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
// Store previous counts for potential rollback
|
||||
const prevOlderCount = olderUnreadCount;
|
||||
const prevRecentCount = recentUnreadCount;
|
||||
|
||||
// Optimistic update: mark all as read immediately for instant UI feedback
|
||||
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
|
||||
setOlderUnreadCount(0);
|
||||
setRecentUnreadCount(0);
|
||||
|
||||
try {
|
||||
// Use the API service with proper Zod validation
|
||||
const result = await notificationsApiService.markAllAsRead();
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to mark all as read");
|
||||
// Rollback counts on error
|
||||
setOlderUnreadCount(prevOlderCount);
|
||||
setRecentUnreadCount(prevRecentCount);
|
||||
}
|
||||
// Electric SQL will sync and live query will ensure consistency
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all as read:", err);
|
||||
// Rollback counts on error
|
||||
setOlderUnreadCount(prevOlderCount);
|
||||
setRecentUnreadCount(prevRecentCount);
|
||||
return false;
|
||||
}
|
||||
}, [olderUnreadCount, recentUnreadCount]);
|
||||
|
||||
return {
|
||||
inboxItems,
|
||||
unreadCount: totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
isUsingApiFallback: true, // Always use API for pagination
|
||||
error,
|
||||
};
|
||||
}
|
||||
154
surfsense_web/hooks/use-messages-electric.ts
Normal file
154
surfsense_web/hooks/use-messages-electric.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { RawMessage } from "@/contracts/types/chat-messages.types";
|
||||
import type { SyncHandle } from "@/lib/electric/client";
|
||||
import { useElectricClient } from "@/lib/electric/context";
|
||||
|
||||
/**
|
||||
* Syncs chat messages for a thread via Electric SQL.
|
||||
* Calls onMessagesUpdate when messages change.
|
||||
*/
|
||||
export function useMessagesElectric(
|
||||
threadId: number | null,
|
||||
onMessagesUpdate: (messages: RawMessage[]) => void
|
||||
) {
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const syncKeyRef = useRef<string | null>(null);
|
||||
const onMessagesUpdateRef = useRef(onMessagesUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
onMessagesUpdateRef.current = onMessagesUpdate;
|
||||
}, [onMessagesUpdate]);
|
||||
|
||||
const handleMessagesUpdate = useCallback((rows: RawMessage[]) => {
|
||||
onMessagesUpdateRef.current(rows);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId || !electricClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncKey = `messages_${threadId}`;
|
||||
if (syncKeyRef.current === syncKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
syncKeyRef.current = syncKey;
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const handle = await client.syncShape({
|
||||
table: "new_chat_messages",
|
||||
where: `thread_id = ${threadId}`,
|
||||
columns: ["id", "thread_id", "role", "content", "author_id", "created_at"],
|
||||
primaryKey: ["id"],
|
||||
});
|
||||
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
try {
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
} catch {
|
||||
// Timeout
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
handle.unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
await fetchMessages();
|
||||
await setupLiveQuery();
|
||||
} catch {
|
||||
// Sync failed
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
try {
|
||||
const result = await client.db.query<RawMessage>(
|
||||
`SELECT id, thread_id, role, content, author_id, created_at
|
||||
FROM new_chat_messages
|
||||
WHERE thread_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[threadId]
|
||||
);
|
||||
|
||||
if (mounted && result.rows) {
|
||||
handleMessagesUpdate(result.rows);
|
||||
}
|
||||
} catch {
|
||||
// Query failed
|
||||
}
|
||||
}
|
||||
|
||||
async function setupLiveQuery() {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = client.db as any;
|
||||
|
||||
if (db.live?.query && typeof db.live.query === "function") {
|
||||
const liveQuery = await db.live.query(
|
||||
`SELECT id, thread_id, role, content, author_id, created_at
|
||||
FROM new_chat_messages
|
||||
WHERE thread_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[threadId]
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (liveQuery.initialResults?.rows) {
|
||||
handleMessagesUpdate(liveQuery.initialResults.rows);
|
||||
} else if (liveQuery.rows) {
|
||||
handleMessagesUpdate(liveQuery.rows);
|
||||
}
|
||||
|
||||
if (typeof liveQuery.subscribe === "function") {
|
||||
liveQuery.subscribe((result: { rows: RawMessage[] }) => {
|
||||
if (mounted && result.rows) {
|
||||
handleMessagesUpdate(result.rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof liveQuery.unsubscribe === "function") {
|
||||
liveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Live query failed
|
||||
}
|
||||
}
|
||||
|
||||
startSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
syncKeyRef.current = null;
|
||||
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [threadId, electricClient, handleMessagesUpdate]);
|
||||
}
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import type { SyncHandle } from "@/lib/electric/client";
|
||||
import { useElectricClient } from "@/lib/electric/context";
|
||||
|
||||
export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
|
||||
|
||||
/**
|
||||
* Hook for managing notifications with Electric SQL real-time sync
|
||||
*
|
||||
* Uses the Electric client from context (provided by ElectricProvider)
|
||||
* instead of initializing its own - prevents race conditions and memory leaks
|
||||
*
|
||||
* Architecture:
|
||||
* - User-level sync: Syncs ALL notifications for a user (runs once per user)
|
||||
* - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change)
|
||||
*
|
||||
* This separation ensures smooth transitions when switching search spaces (no flash).
|
||||
*
|
||||
* @param userId - The user ID to fetch notifications for
|
||||
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
|
||||
* @param typeFilter - Optional notification type to filter by (null shows all types)
|
||||
*/
|
||||
export function useNotifications(
|
||||
userId: string | null,
|
||||
searchSpaceId: number | null,
|
||||
typeFilter: NotificationTypeEnum | null = null
|
||||
) {
|
||||
// Get Electric client from context - ElectricProvider handles initialization
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
|
||||
// Track user-level sync key to prevent duplicate sync subscriptions
|
||||
const userSyncKeyRef = useRef<string | null>(null);
|
||||
|
||||
// EFFECT 1: User-level sync - runs once per user, syncs ALL notifications
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
setLoading(!electricClient);
|
||||
return;
|
||||
}
|
||||
|
||||
const userSyncKey = `notifications_${userId}`;
|
||||
if (userSyncKeyRef.current === userSyncKey) {
|
||||
// Already syncing for this user
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
userSyncKeyRef.current = userSyncKey;
|
||||
|
||||
async function startUserSync() {
|
||||
try {
|
||||
console.log("[useNotifications] Starting user-level sync for:", userId);
|
||||
|
||||
// Sync ALL notifications for this user (cached via syncShape caching)
|
||||
const handle = await electricClient.syncShape({
|
||||
table: "notifications",
|
||||
where: `user_id = '${userId}'`,
|
||||
primaryKey: ["id"],
|
||||
});
|
||||
|
||||
console.log("[useNotifications] User sync started:", {
|
||||
isUpToDate: handle.isUpToDate,
|
||||
});
|
||||
|
||||
// Wait for initial sync with timeout
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
try {
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 2000)),
|
||||
]);
|
||||
} catch (syncErr) {
|
||||
console.error("[useNotifications] Initial sync failed:", syncErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
handle.unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error("[useNotifications] Failed to start user sync:", err);
|
||||
setError(err instanceof Error ? err : new Error("Failed to sync notifications"));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
startUserSync();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
userSyncKeyRef.current = null;
|
||||
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, electricClient]);
|
||||
|
||||
// EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
|
||||
// This runs independently of sync, allowing smooth transitions between search spaces
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
async function updateQuery() {
|
||||
// Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
"[useNotifications] Updating query for searchSpace:",
|
||||
searchSpaceId,
|
||||
"typeFilter:",
|
||||
typeFilter
|
||||
);
|
||||
|
||||
// Build query with optional type filter
|
||||
const baseQuery = `SELECT * FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)`;
|
||||
const typeClause = typeFilter ? ` AND type = $3` : "";
|
||||
const orderClause = ` ORDER BY created_at DESC`;
|
||||
const fullQuery = baseQuery + typeClause + orderClause;
|
||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||
|
||||
// Fetch notifications for current search space immediately
|
||||
const result = await electricClient.db.query<Notification>(fullQuery, params);
|
||||
|
||||
if (mounted) {
|
||||
setNotifications(result.rows || []);
|
||||
}
|
||||
|
||||
// Set up live query for real-time updates
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = electricClient.db as any;
|
||||
|
||||
if (db.live?.query && typeof db.live.query === "function") {
|
||||
const liveQuery = await db.live.query(fullQuery, params);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial results from live query
|
||||
if (liveQuery.initialResults?.rows) {
|
||||
setNotifications(liveQuery.initialResults.rows);
|
||||
} else if (liveQuery.rows) {
|
||||
setNotifications(liveQuery.rows);
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
if (typeof liveQuery.subscribe === "function") {
|
||||
liveQuery.subscribe((result: { rows: Notification[] }) => {
|
||||
if (mounted && result.rows) {
|
||||
setNotifications(result.rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof liveQuery.unsubscribe === "function") {
|
||||
liveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useNotifications] Failed to update query:", err);
|
||||
}
|
||||
}
|
||||
|
||||
updateQuery();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (liveQueryRef.current) {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
|
||||
// EFFECT 3: Total unread count - independent of type filter
|
||||
// This ensures the badge count stays consistent regardless of active filter
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
async function updateUnreadCount() {
|
||||
// Clean up previous live query
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const countQuery = `SELECT COUNT(*) as count FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||
AND read = false`;
|
||||
|
||||
// Fetch initial count
|
||||
const result = await electricClient.db.query<{ count: number }>(countQuery, [
|
||||
userId,
|
||||
searchSpaceId,
|
||||
]);
|
||||
|
||||
if (mounted && result.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(result.rows[0].count) || 0);
|
||||
}
|
||||
|
||||
// Set up live query for real-time updates
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = electricClient.db as any;
|
||||
|
||||
if (db.live?.query && typeof db.live.query === "function") {
|
||||
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial results from live query
|
||||
if (liveQuery.initialResults?.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
|
||||
} else if (liveQuery.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
if (typeof liveQuery.subscribe === "function") {
|
||||
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
|
||||
if (mounted && result.rows?.[0]) {
|
||||
setTotalUnreadCount(Number(result.rows[0].count) || 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof liveQuery.unsubscribe === "function") {
|
||||
unreadCountLiveQueryRef.current = liveQuery;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useNotifications] Failed to update unread count:", err);
|
||||
}
|
||||
}
|
||||
|
||||
updateUnreadCount();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (unreadCountLiveQueryRef.current) {
|
||||
unreadCountLiveQueryRef.current.unsubscribe();
|
||||
unreadCountLiveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, electricClient]);
|
||||
|
||||
// Mark notification as read via backend API
|
||||
const markAsRead = useCallback(async (notificationId: number) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
|
||||
{ method: "PATCH" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
|
||||
throw new Error(error.detail || "Failed to mark notification as read");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark notification as read:", err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Mark all notifications as read via backend API
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
|
||||
{ method: "PATCH" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
|
||||
throw new Error(error.detail || "Failed to mark all notifications as read");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all notifications as read:", err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount: totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
110
surfsense_web/lib/apis/notifications-api.service.ts
Normal file
110
surfsense_web/lib/apis/notifications-api.service.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
type GetNotificationsRequest,
|
||||
type GetNotificationsResponse,
|
||||
type GetUnreadCountResponse,
|
||||
getNotificationsRequest,
|
||||
getNotificationsResponse,
|
||||
getUnreadCountResponse,
|
||||
type MarkAllNotificationsReadResponse,
|
||||
type MarkNotificationReadRequest,
|
||||
type MarkNotificationReadResponse,
|
||||
markAllNotificationsReadResponse,
|
||||
markNotificationReadRequest,
|
||||
markNotificationReadResponse,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class NotificationsApiService {
|
||||
/**
|
||||
* Get notifications with pagination
|
||||
*/
|
||||
getNotifications = async (
|
||||
request: GetNotificationsRequest
|
||||
): Promise<GetNotificationsResponse> => {
|
||||
const parsedRequest = getNotificationsRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { queryParams } = parsedRequest.data;
|
||||
|
||||
// Build query string from params
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (queryParams.search_space_id !== undefined) {
|
||||
params.append("search_space_id", String(queryParams.search_space_id));
|
||||
}
|
||||
if (queryParams.type) {
|
||||
params.append("type", queryParams.type);
|
||||
}
|
||||
if (queryParams.before_date) {
|
||||
params.append("before_date", queryParams.before_date);
|
||||
}
|
||||
if (queryParams.limit !== undefined) {
|
||||
params.append("limit", String(queryParams.limit));
|
||||
}
|
||||
if (queryParams.offset !== undefined) {
|
||||
params.append("offset", String(queryParams.offset));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/notifications${queryString ? `?${queryString}` : ""}`,
|
||||
getNotificationsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
markAsRead = async (
|
||||
request: MarkNotificationReadRequest
|
||||
): Promise<MarkNotificationReadResponse> => {
|
||||
const parsedRequest = markNotificationReadRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { notificationId } = parsedRequest.data;
|
||||
|
||||
return baseApiService.patch(
|
||||
`/api/v1/notifications/${notificationId}/read`,
|
||||
markNotificationReadResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
markAllAsRead = async (): Promise<MarkAllNotificationsReadResponse> => {
|
||||
return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get unread notification count with split between total and recent
|
||||
* - total_unread: All unread notifications
|
||||
* - recent_unread: Unread within sync window (last 14 days)
|
||||
*/
|
||||
getUnreadCount = async (searchSpaceId?: number): Promise<GetUnreadCountResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchSpaceId !== undefined) {
|
||||
params.append("search_space_id", String(searchSpaceId));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/notifications/unread-count${queryString ? `?${queryString}` : ""}`,
|
||||
getUnreadCountResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationsApiService = new NotificationsApiService();
|
||||
|
|
@ -53,8 +53,9 @@ const activeSyncHandles = new Map<string, SyncHandle>();
|
|||
const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
||||
|
||||
// Version for sync state - increment this to force fresh sync when Electric config changes
|
||||
// Set to v2 for user-specific database architecture
|
||||
const SYNC_VERSION = 2;
|
||||
// v2: user-specific database architecture
|
||||
// v3: consistent cutoff date for sync+queries, visibility refresh support
|
||||
const SYNC_VERSION = 3;
|
||||
|
||||
// Database name prefix for identifying SurfSense databases
|
||||
const DB_PREFIX = "surfsense-";
|
||||
|
|
@ -228,7 +229,6 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
|
||||
`);
|
||||
|
||||
// Create the chat_comment_mentions table schema in PGlite
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
|
@ -241,6 +241,39 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
|
||||
`);
|
||||
|
||||
// Create chat_comments table for live comment sync
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_comments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
parent_id INTEGER,
|
||||
author_id TEXT,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id);
|
||||
`);
|
||||
|
||||
// Create new_chat_messages table for live message sync
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS new_chat_messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
thread_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
author_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_new_chat_messages_thread_id ON new_chat_messages(thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_new_chat_messages_created_at ON new_chat_messages(created_at);
|
||||
`);
|
||||
|
||||
const electricUrl = getElectricUrl();
|
||||
|
||||
// STEP 4: Create the client wrapper
|
||||
|
|
|
|||
|
|
@ -683,6 +683,7 @@
|
|||
"select_search_space": "Select Search Space",
|
||||
"manage_members": "Manage members",
|
||||
"search_space_settings": "Search Space settings",
|
||||
"logs": "Logs",
|
||||
"see_all_search_spaces": "See all search spaces",
|
||||
"expand_sidebar": "Expand sidebar",
|
||||
"collapse_sidebar": "Collapse sidebar",
|
||||
|
|
@ -692,7 +693,23 @@
|
|||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"inbox": "Inbox",
|
||||
"search_inbox": "Search inbox",
|
||||
"mark_all_read": "Mark all as read",
|
||||
"mark_as_read": "Mark as read",
|
||||
"mentions": "Mentions",
|
||||
"status": "Status",
|
||||
"no_results_found": "No results found",
|
||||
"no_mentions": "No mentions",
|
||||
"no_mentions_hint": "You'll see mentions from others here",
|
||||
"no_status_updates": "No status updates",
|
||||
"no_status_updates_hint": "Document and connector updates will appear here",
|
||||
"filter": "Filter",
|
||||
"all": "All",
|
||||
"unread": "Unread",
|
||||
"connectors": "Connectors",
|
||||
"all_connectors": "All connectors"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "Something went wrong",
|
||||
|
|
|
|||
|
|
@ -668,6 +668,7 @@
|
|||
"select_search_space": "选择搜索空间",
|
||||
"manage_members": "管理成员",
|
||||
"search_space_settings": "搜索空间设置",
|
||||
"logs": "日志",
|
||||
"see_all_search_spaces": "查看所有搜索空间",
|
||||
"expand_sidebar": "展开侧边栏",
|
||||
"collapse_sidebar": "收起侧边栏",
|
||||
|
|
@ -677,7 +678,23 @@
|
|||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "系统",
|
||||
"logout": "退出登录"
|
||||
"logout": "退出登录",
|
||||
"inbox": "收件箱",
|
||||
"search_inbox": "搜索收件箱...",
|
||||
"mark_all_read": "全部标记为已读",
|
||||
"mark_as_read": "标记为已读",
|
||||
"mentions": "提及",
|
||||
"status": "状态",
|
||||
"no_results_found": "未找到结果",
|
||||
"no_mentions": "没有提及",
|
||||
"no_mentions_hint": "您会在这里看到他人的提及",
|
||||
"no_status_updates": "没有状态更新",
|
||||
"no_status_updates_hint": "文档和连接器更新将显示在这里",
|
||||
"filter": "筛选",
|
||||
"all": "全部",
|
||||
"unread": "未读",
|
||||
"connectors": "连接器",
|
||||
"all_connectors": "所有连接器"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "出错了",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@
|
|||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
|
|
|
|||
18
surfsense_web/pnpm-lock.yaml
generated
18
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -260,6 +260,9 @@ importers:
|
|||
unist-util-visit:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
vaul:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
zod:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
|
|
@ -6384,6 +6387,12 @@ packages:
|
|||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
vaul@1.1.2:
|
||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
|
|
@ -13451,6 +13460,15 @@ snapshots:
|
|||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue