Merge pull request #691 from MODSetter/dev

feat: ux updates
This commit is contained in:
Rohan Verma 2026-01-13 02:16:25 -08:00 committed by GitHub
commit f3f661f33e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2784 additions and 1575 deletions

View file

@ -17,7 +17,7 @@
# SurfSense # SurfSense
Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team. OSS alternative to NotebookLM, Perplexity, and Glean. Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team. OSS alternative to NotebookLM, Perplexity, and Glean.
SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come. SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come.
<div align="center"> <div align="center">
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@ -97,6 +97,7 @@ Contributors can easily add new tools via the registry pattern:
- SearxNG (self-hosted instances) - SearxNG (self-hosted instances)
- Google Drive - Google Drive
- Slack - Slack
- Microsoft Teams
- Linear - Linear
- Jira - Jira
- ClickUp - ClickUp

View file

@ -18,7 +18,7 @@
将任何 LLM 连接到您的内部知识源并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。 将任何 LLM 连接到您的内部知识源并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。
SurfSense 是一个高度可定制的 AI 研究助手可以连接外部数据源如搜索引擎SearxNG、Tavily、LinkUp、Google Drive、Slack、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。 SurfSense 是一个高度可定制的 AI 研究助手可以连接外部数据源如搜索引擎SearxNG、Tavily、LinkUp、Google Drive、Slack、Microsoft Teams、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。
<div align="center"> <div align="center">
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@ -105,6 +105,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
- SearxNG自托管实例 - SearxNG自托管实例
- Google Drive - Google Drive
- Slack - Slack
- Microsoft Teams
- Linear - Linear
- Jira - Jira
- ClickUp - ClickUp

View file

@ -0,0 +1,109 @@
"""Add chat visibility and created_by_id columns to new_chat_threads
This migration adds:
- ChatVisibility enum (PRIVATE, SEARCH_SPACE)
- visibility column to new_chat_threads table (default: PRIVATE)
- created_by_id column to track who created the chat thread
Revision ID: 61
Revises: 60
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "61"
down_revision: str | None = "60"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add visibility and created_by_id columns to new_chat_threads."""
# Create the ChatVisibility enum type
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'chatvisibility') THEN
CREATE TYPE chatvisibility AS ENUM ('PRIVATE', 'SEARCH_SPACE');
END IF;
END$$;
"""
)
# Add visibility column with default value PRIVATE
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'new_chat_threads' AND column_name = 'visibility'
) THEN
ALTER TABLE new_chat_threads
ADD COLUMN visibility chatvisibility NOT NULL DEFAULT 'PRIVATE';
END IF;
END$$;
"""
)
# Create index on visibility column for efficient filtering
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_new_chat_threads_visibility
ON new_chat_threads(visibility);
"""
)
# Add created_by_id column (nullable to handle existing records)
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'new_chat_threads' AND column_name = 'created_by_id'
) THEN
ALTER TABLE new_chat_threads
ADD COLUMN created_by_id UUID REFERENCES "user"(id) ON DELETE SET NULL;
END IF;
END$$;
"""
)
# Create index on created_by_id column for efficient filtering
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_new_chat_threads_created_by_id
ON new_chat_threads(created_by_id);
"""
)
def downgrade() -> None:
"""Remove visibility and created_by_id columns from new_chat_threads."""
# Drop indexes
op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_created_by_id")
op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_visibility")
# Drop columns
op.execute(
"""
ALTER TABLE new_chat_threads
DROP COLUMN IF EXISTS created_by_id;
"""
)
op.execute(
"""
ALTER TABLE new_chat_threads
DROP COLUMN IF EXISTS visibility;
"""
)
# Drop enum type (only if not used elsewhere)
op.execute("DROP TYPE IF EXISTS chatvisibility")

View file

@ -326,6 +326,20 @@ class NewChatMessageRole(str, Enum):
SYSTEM = "system" SYSTEM = "system"
class ChatVisibility(str, Enum):
"""
Visibility/sharing level for chat threads.
PRIVATE: Only the creator can see/access the chat (default)
SEARCH_SPACE: All members of the search space can see/access the chat
PUBLIC: (Future) Anyone with the link can access the chat
"""
PRIVATE = "PRIVATE"
SEARCH_SPACE = "SEARCH_SPACE"
# PUBLIC = "PUBLIC" # Reserved for future implementation
class NewChatThread(BaseModel, TimestampMixin): class NewChatThread(BaseModel, TimestampMixin):
""" """
Thread model for the new chat feature using assistant-ui. Thread model for the new chat feature using assistant-ui.
@ -345,13 +359,31 @@ class NewChatThread(BaseModel, TimestampMixin):
index=True, index=True,
) )
# Visibility/sharing control
visibility = Column(
SQLAlchemyEnum(ChatVisibility),
nullable=False,
default=ChatVisibility.PRIVATE,
server_default="PRIVATE",
index=True,
)
# Foreign keys # Foreign keys
search_space_id = Column( search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
) )
# Track who created this chat thread (for visibility filtering)
created_by_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True, # Nullable for existing records before migration
index=True,
)
# Relationships # Relationships
search_space = relationship("SearchSpace", back_populates="new_chat_threads") search_space = relationship("SearchSpace", back_populates="new_chat_threads")
created_by = relationship("User", back_populates="new_chat_threads")
messages = relationship( messages = relationship(
"NewChatMessage", "NewChatMessage",
back_populates="thread", back_populates="thread",
@ -826,6 +858,13 @@ if config.AUTH_TYPE == "GOOGLE":
passive_deletes=True, passive_deletes=True,
) )
# Chat threads created by this user
new_chat_threads = relationship(
"NewChatThread",
back_populates="created_by",
passive_deletes=True,
)
# Page usage tracking for ETL services # Page usage tracking for ETL services
pages_limit = Column( pages_limit = Column(
Integer, Integer,
@ -852,6 +891,13 @@ else:
passive_deletes=True, passive_deletes=True,
) )
# Chat threads created by this user
new_chat_threads = relationship(
"NewChatThread",
back_populates="created_by",
passive_deletes=True,
)
# Page usage tracking for ETL services # Page usage tracking for ETL services
pages_limit = Column( pages_limit = Column(
Integer, Integer,

View file

@ -19,12 +19,14 @@ from datetime import UTC, datetime
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.db import ( from app.db import (
ChatVisibility,
NewChatMessage, NewChatMessage,
NewChatMessageRole, NewChatMessageRole,
NewChatThread, NewChatThread,
@ -40,6 +42,7 @@ from app.schemas.new_chat import (
NewChatThreadCreate, NewChatThreadCreate,
NewChatThreadRead, NewChatThreadRead,
NewChatThreadUpdate, NewChatThreadUpdate,
NewChatThreadVisibilityUpdate,
NewChatThreadWithMessages, NewChatThreadWithMessages,
ThreadHistoryLoadResponse, ThreadHistoryLoadResponse,
ThreadListItem, ThreadListItem,
@ -52,6 +55,82 @@ from app.utils.rbac import check_permission
router = APIRouter() router = APIRouter()
async def check_thread_access(
session: AsyncSession,
thread: NewChatThread,
user: User,
require_ownership: bool = False,
) -> bool:
"""
Check if a user has access to a thread based on visibility rules.
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE (any member can access)
- Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner
Args:
session: Database session
thread: The thread to check access for
user: The user requesting access
require_ownership: If True, only the creator can access (for edit/delete operations)
For SEARCH_SPACE threads, any member with permission can access
Legacy threads (NULL creator) are accessible by search space owner
Returns:
True if access is granted
Raises:
HTTPException: If access is denied
"""
is_owner = thread.created_by_id == user.id
is_legacy = thread.created_by_id is None
# Shared threads (SEARCH_SPACE) are accessible by any member
# This check comes first so shared threads are always accessible
if thread.visibility == ChatVisibility.SEARCH_SPACE:
# For ownership-required operations on shared threads, any member can proceed
# (permission check is done at route level)
return True
# For legacy threads (created before visibility feature),
# only the search space owner can access
if is_legacy:
search_space_query = select(SearchSpace).filter(
SearchSpace.id == thread.search_space_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
if is_search_space_owner:
return True
# Legacy threads are not accessible to non-owners
raise HTTPException(
status_code=403,
detail="You don't have access to this chat",
)
# If ownership is required, only the creator can access
if require_ownership:
if not is_owner:
raise HTTPException(
status_code=403,
detail="Only the creator of this chat can perform this action",
)
return True
# For read access: owner can access their own private threads
if is_owner:
return True
# Private thread and user is not the owner
raise HTTPException(
status_code=403,
detail="You don't have access to this private chat",
)
# ============================================================================= # =============================================================================
# Thread Endpoints # Thread Endpoints
# ============================================================================= # =============================================================================
@ -65,9 +144,14 @@ async def list_threads(
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
""" """
List all threads for the current user in a search space. List all accessible threads for the current user in a search space.
Returns threads and archived_threads for ThreadListPrimitive. Returns threads and archived_threads for ThreadListPrimitive.
A user can see threads that are:
- Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
Args: Args:
search_space_id: The search space to list threads for search_space_id: The search space to list threads for
limit: Optional limit on number of threads to return (applies to active threads only) limit: Optional limit on number of threads to return (applies to active threads only)
@ -83,10 +167,33 @@ async def list_threads(
"You don't have permission to read chats in this search space", "You don't have permission to read chats in this search space",
) )
# Get all threads in this search space # Check if user is the search space owner (for legacy thread visibility)
search_space_query = select(SearchSpace).filter(
SearchSpace.id == search_space_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
# Build filter conditions:
# 1. Created by the current user (any visibility)
# 2. Shared with the search space (visibility = SEARCH_SPACE)
# 3. Legacy threads (created_by_id is NULL) - only visible to search space owner
filter_conditions = [
NewChatThread.created_by_id == user.id,
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
# Only include legacy threads for the search space owner
if is_search_space_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
query = ( query = (
select(NewChatThread) select(NewChatThread)
.filter(NewChatThread.search_space_id == search_space_id) .filter(
NewChatThread.search_space_id == search_space_id,
or_(*filter_conditions),
)
.order_by(NewChatThread.updated_at.desc()) .order_by(NewChatThread.updated_at.desc())
) )
@ -98,10 +205,17 @@ async def list_threads(
archived_threads = [] archived_threads = []
for thread in all_threads: for thread in all_threads:
# Legacy threads (no creator) are treated as own threads for owner
is_own_thread = thread.created_by_id == user.id or (
thread.created_by_id is None and is_search_space_owner
)
item = ThreadListItem( item = ThreadListItem(
id=thread.id, id=thread.id,
title=thread.title, title=thread.title,
archived=thread.archived, archived=thread.archived,
visibility=thread.visibility,
created_by_id=thread.created_by_id,
is_own_thread=is_own_thread,
created_at=thread.created_at, created_at=thread.created_at,
updated_at=thread.updated_at, updated_at=thread.updated_at,
) )
@ -137,7 +251,12 @@ async def search_threads(
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
""" """
Search threads by title in a search space. Search accessible threads by title in a search space.
A user can search threads that are:
- Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
Args: Args:
search_space_id: The search space to search in search_space_id: The search space to search in
@ -154,12 +273,31 @@ async def search_threads(
"You don't have permission to read chats in this search space", "You don't have permission to read chats in this search space",
) )
# Search threads by title (case-insensitive) # Check if user is the search space owner (for legacy thread visibility)
search_space_query = select(SearchSpace).filter(
SearchSpace.id == search_space_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
# Build filter conditions
filter_conditions = [
NewChatThread.created_by_id == user.id,
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
# Only include legacy threads for the search space owner
if is_search_space_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
# Search accessible threads by title (case-insensitive)
query = ( query = (
select(NewChatThread) select(NewChatThread)
.filter( .filter(
NewChatThread.search_space_id == search_space_id, NewChatThread.search_space_id == search_space_id,
NewChatThread.title.ilike(f"%{title}%"), NewChatThread.title.ilike(f"%{title}%"),
or_(*filter_conditions),
) )
.order_by(NewChatThread.updated_at.desc()) .order_by(NewChatThread.updated_at.desc())
) )
@ -172,6 +310,13 @@ async def search_threads(
id=thread.id, id=thread.id,
title=thread.title, title=thread.title,
archived=thread.archived, archived=thread.archived,
visibility=thread.visibility,
created_by_id=thread.created_by_id,
# Legacy threads (no creator) are treated as own threads for owner
is_own_thread=(
thread.created_by_id == user.id
or (thread.created_by_id is None and is_search_space_owner)
),
created_at=thread.created_at, created_at=thread.created_at,
updated_at=thread.updated_at, updated_at=thread.updated_at,
) )
@ -200,6 +345,9 @@ async def create_thread(
""" """
Create a new chat thread. Create a new chat thread.
The thread is created with the specified visibility (defaults to PRIVATE).
The current user is recorded as the creator of the thread.
Requires CHATS_CREATE permission. Requires CHATS_CREATE permission.
""" """
try: try:
@ -215,7 +363,9 @@ async def create_thread(
db_thread = NewChatThread( db_thread = NewChatThread(
title=thread.title, title=thread.title,
archived=thread.archived, archived=thread.archived,
visibility=thread.visibility,
search_space_id=thread.search_space_id, search_space_id=thread.search_space_id,
created_by_id=user.id,
updated_at=now, updated_at=now,
) )
session.add(db_thread) session.add(db_thread)
@ -254,6 +404,10 @@ async def get_thread_messages(
Get a thread with all its messages. Get a thread with all its messages.
This is used by ThreadHistoryAdapter.load() to restore conversation. This is used by ThreadHistoryAdapter.load() to restore conversation.
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE
Requires CHATS_READ permission. Requires CHATS_READ permission.
""" """
try: try:
@ -268,7 +422,7 @@ async def get_thread_messages(
if not thread: if not thread:
raise HTTPException(status_code=404, detail="Thread not found") raise HTTPException(status_code=404, detail="Thread not found")
# Check permission and ownership # Check permission to read chats in this search space
await check_permission( await check_permission(
session, session,
user, user,
@ -277,6 +431,9 @@ async def get_thread_messages(
"You don't have permission to read chats in this search space", "You don't have permission to read chats in this search space",
) )
# Check thread-level access based on visibility
await check_thread_access(session, thread, user)
# Return messages in the format expected by assistant-ui # Return messages in the format expected by assistant-ui
messages = [ messages = [
NewChatMessageRead( NewChatMessageRead(
@ -313,6 +470,10 @@ async def get_thread_full(
""" """
Get full thread details with all messages. Get full thread details with all messages.
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE
Requires CHATS_READ permission. Requires CHATS_READ permission.
""" """
try: try:
@ -334,6 +495,9 @@ async def get_thread_full(
"You don't have permission to read chats in this search space", "You don't have permission to read chats in this search space",
) )
# Check thread-level access based on visibility
await check_thread_access(session, thread, user)
return thread return thread
except HTTPException: except HTTPException:
@ -360,6 +524,9 @@ async def update_thread(
Update a thread (title, archived status). Update a thread (title, archived status).
Used for renaming and archiving threads. Used for renaming and archiving threads.
- PRIVATE threads: Only the creator can update
- SEARCH_SPACE threads: Any member with CHATS_UPDATE permission can update
Requires CHATS_UPDATE permission. Requires CHATS_UPDATE permission.
""" """
try: try:
@ -379,6 +546,11 @@ async def update_thread(
"You don't have permission to update chats in this search space", "You don't have permission to update chats in this search space",
) )
# For PRIVATE threads, only the creator can update
# For SEARCH_SPACE threads, any member with permission can update
if db_thread.visibility == ChatVisibility.PRIVATE:
await check_thread_access(session, db_thread, user, require_ownership=True)
# Update fields # Update fields
update_data = thread_update.model_dump(exclude_unset=True) update_data = thread_update.model_dump(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
@ -420,6 +592,9 @@ async def delete_thread(
""" """
Delete a thread and all its messages. Delete a thread and all its messages.
- PRIVATE threads: Only the creator can delete
- SEARCH_SPACE threads: Any member with CHATS_DELETE permission can delete
Requires CHATS_DELETE permission. Requires CHATS_DELETE permission.
""" """
try: try:
@ -439,6 +614,11 @@ async def delete_thread(
"You don't have permission to delete chats in this search space", "You don't have permission to delete chats in this search space",
) )
# For PRIVATE threads, only the creator can delete
# For SEARCH_SPACE threads, any member with permission can delete
if db_thread.visibility == ChatVisibility.PRIVATE:
await check_thread_access(session, db_thread, user, require_ownership=True)
await session.delete(db_thread) await session.delete(db_thread)
await session.commit() await session.commit()
return {"message": "Thread deleted successfully"} return {"message": "Thread deleted successfully"}
@ -463,6 +643,71 @@ async def delete_thread(
) from None ) from None
@router.patch("/threads/{thread_id}/visibility", response_model=NewChatThreadRead)
async def update_thread_visibility(
thread_id: int,
visibility_update: NewChatThreadVisibilityUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update the visibility/sharing settings of a thread.
Only the creator of the thread can change its visibility.
- PRIVATE: Only the creator can access the thread (default)
- SEARCH_SPACE: All members of the search space can access the thread
Requires CHATS_UPDATE permission.
"""
try:
result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == thread_id)
)
db_thread = result.scalars().first()
if not db_thread:
raise HTTPException(status_code=404, detail="Thread not found")
await check_permission(
session,
user,
db_thread.search_space_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
)
# Only the creator can change visibility
await check_thread_access(session, db_thread, user, require_ownership=True)
# Update visibility
db_thread.visibility = visibility_update.visibility
db_thread.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(db_thread)
return db_thread
except HTTPException:
raise
except IntegrityError:
await session.rollback()
raise HTTPException(
status_code=400,
detail="Database constraint violation. Please check your input data.",
) from None
except OperationalError:
await session.rollback()
raise HTTPException(
status_code=503, detail="Database operation failed. Please try again later."
) from None
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred while updating thread visibility: {e!s}",
) from None
# ============================================================================= # =============================================================================
# Message Endpoints # Message Endpoints
# ============================================================================= # =============================================================================
@ -479,6 +724,10 @@ async def append_message(
Append a message to a thread. Append a message to a thread.
This is used by ThreadHistoryAdapter.append() to persist messages. This is used by ThreadHistoryAdapter.append() to persist messages.
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE
Requires CHATS_UPDATE permission. Requires CHATS_UPDATE permission.
""" """
try: try:
@ -513,6 +762,9 @@ async def append_message(
"You don't have permission to update chats in this search space", "You don't have permission to update chats in this search space",
) )
# Check thread-level access based on visibility
await check_thread_access(session, thread, user)
# Convert string role to enum # Convert string role to enum
role_str = ( role_str = (
message.role.lower() if isinstance(message.role, str) else message.role message.role.lower() if isinstance(message.role, str) else message.role
@ -597,6 +849,10 @@ async def list_messages(
""" """
List messages in a thread with pagination. List messages in a thread with pagination.
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE
Requires CHATS_READ permission. Requires CHATS_READ permission.
""" """
try: try:
@ -617,6 +873,9 @@ async def list_messages(
"You don't have permission to read chats in this search space", "You don't have permission to read chats in this search space",
) )
# Check thread-level access based on visibility
await check_thread_access(session, thread, user)
# Get messages # Get messages
query = ( query = (
select(NewChatMessage) select(NewChatMessage)
@ -659,6 +918,10 @@ async def handle_new_chat(
This endpoint handles the new chat functionality with streaming responses This endpoint handles the new chat functionality with streaming responses
using Server-Sent Events (SSE) format compatible with Vercel AI SDK. using Server-Sent Events (SSE) format compatible with Vercel AI SDK.
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE
Requires CHATS_CREATE permission. Requires CHATS_CREATE permission.
""" """
try: try:
@ -679,6 +942,9 @@ async def handle_new_chat(
"You don't have permission to chat in this search space", "You don't have permission to chat in this search space",
) )
# Check thread-level access based on visibility
await check_thread_access(session, thread, user)
# Get search space to check LLM config preferences # Get search space to check LLM config preferences
search_space_result = await session.execute( search_space_result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == request.search_space_id) select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
@ -706,6 +972,7 @@ async def handle_new_chat(
llm_config_id=llm_config_id, llm_config_id=llm_config_id,
attachments=request.attachments, attachments=request.attachments,
mentioned_document_ids=request.mentioned_document_ids, mentioned_document_ids=request.mentioned_document_ids,
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
), ),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={

View file

@ -556,6 +556,54 @@ async def update_member_role(
) from e ) from e
# NOTE: /members/me must be defined BEFORE /members/{membership_id}
# because FastAPI matches routes in order, and "me" would otherwise
# be interpreted as a membership_id (causing a 422 validation error)
@router.delete("/searchspaces/{search_space_id}/members/me")
async def leave_search_space(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Leave a search space (remove own membership).
Owners cannot leave their search space.
"""
try:
result = await session.execute(
select(SearchSpaceMembership).filter(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == search_space_id,
)
)
db_membership = result.scalars().first()
if not db_membership:
raise HTTPException(
status_code=404,
detail="You are not a member of this search space",
)
if db_membership.is_owner:
raise HTTPException(
status_code=400,
detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
)
await session.delete(db_membership)
await session.commit()
return {"message": "Successfully left the search space"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to leave search space: {e!s}"
) from e
@router.delete("/searchspaces/{search_space_id}/members/{membership_id}") @router.delete("/searchspaces/{search_space_id}/members/{membership_id}")
async def remove_member( async def remove_member(
search_space_id: int, search_space_id: int,
@ -608,51 +656,6 @@ async def remove_member(
) from e ) from e
@router.delete("/searchspaces/{search_space_id}/members/me")
async def leave_search_space(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Leave a search space (remove own membership).
Owners cannot leave their search space.
"""
try:
result = await session.execute(
select(SearchSpaceMembership).filter(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == search_space_id,
)
)
db_membership = result.scalars().first()
if not db_membership:
raise HTTPException(
status_code=404,
detail="You are not a member of this search space",
)
if db_membership.is_owner:
raise HTTPException(
status_code=400,
detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
)
await session.delete(db_membership)
await session.commit()
return {"message": "Successfully left the search space"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to leave search space: {e!s}"
) from e
# ============ Invite Endpoints ============ # ============ Invite Endpoints ============

View file

@ -7,7 +7,7 @@ on a [citation:doc-XXX] link.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -17,8 +17,10 @@ from app.db import (
User, User,
get_async_session, get_async_session,
) )
from app.schemas import PaginatedResponse
from app.schemas.surfsense_docs import ( from app.schemas.surfsense_docs import (
SurfsenseDocsChunkRead, SurfsenseDocsChunkRead,
SurfsenseDocsDocumentRead,
SurfsenseDocsDocumentWithChunksRead, SurfsenseDocsDocumentWithChunksRead,
) )
from app.users import current_active_user from app.users import current_active_user
@ -87,3 +89,81 @@ async def get_surfsense_doc_by_chunk_id(
status_code=500, status_code=500,
detail=f"Failed to retrieve Surfsense documentation: {e!s}", detail=f"Failed to retrieve Surfsense documentation: {e!s}",
) from e ) from e
@router.get(
"/surfsense-docs",
response_model=PaginatedResponse[SurfsenseDocsDocumentRead],
)
async def list_surfsense_docs(
page: int = 0,
page_size: int = 50,
title: str | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List all Surfsense documentation documents.
Args:
page: Zero-based page index.
page_size: Number of items per page (default: 50).
title: Optional title filter (case-insensitive substring match).
session: Database session (injected).
user: Current authenticated user (injected).
Returns:
PaginatedResponse[SurfsenseDocsDocumentRead]: Paginated list of Surfsense docs.
"""
try:
# Base query
query = select(SurfsenseDocsDocument)
count_query = select(func.count()).select_from(SurfsenseDocsDocument)
# Filter by title if provided
if title and title.strip():
query = query.filter(SurfsenseDocsDocument.title.ilike(f"%{title}%"))
count_query = count_query.filter(
SurfsenseDocsDocument.title.ilike(f"%{title}%")
)
# Get total count
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# Calculate offset
offset = page * page_size
# Get paginated results
result = await session.execute(
query.order_by(SurfsenseDocsDocument.title).offset(offset).limit(page_size)
)
docs = result.scalars().all()
# Convert to response format
items = [
SurfsenseDocsDocumentRead(
id=doc.id,
title=doc.title,
source=doc.source,
content=doc.content,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
for doc in docs
]
has_more = (offset + len(items)) < total
return PaginatedResponse(
items=items,
total=total,
page=page,
page_size=page_size,
has_more=has_more,
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to list Surfsense documentation: {e!s}",
) from e

View file

@ -8,10 +8,11 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern:
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.db import NewChatMessageRole from app.db import ChatVisibility, NewChatMessageRole
from .base import IDModel, TimestampModel from .base import IDModel, TimestampModel
@ -66,6 +67,8 @@ class NewChatThreadCreate(NewChatThreadBase):
"""Schema for creating a new thread.""" """Schema for creating a new thread."""
search_space_id: int search_space_id: int
# Visibility defaults to PRIVATE, but can be set on creation
visibility: ChatVisibility = ChatVisibility.PRIVATE
class NewChatThreadUpdate(BaseModel): class NewChatThreadUpdate(BaseModel):
@ -75,12 +78,20 @@ class NewChatThreadUpdate(BaseModel):
archived: bool | None = None archived: bool | None = None
class NewChatThreadVisibilityUpdate(BaseModel):
"""Schema for updating thread visibility/sharing settings."""
visibility: ChatVisibility
class NewChatThreadRead(NewChatThreadBase, IDModel): class NewChatThreadRead(NewChatThreadBase, IDModel):
""" """
Schema for reading a thread (matches assistant-ui ThreadRecord). Schema for reading a thread (matches assistant-ui ThreadRecord).
""" """
search_space_id: int search_space_id: int
visibility: ChatVisibility
created_by_id: UUID | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -116,6 +127,9 @@ class ThreadListItem(BaseModel):
id: int id: int
title: str title: str
archived: bool archived: bool
visibility: ChatVisibility
created_by_id: UUID | None = None
is_own_thread: bool = False # True if the current user created this thread
created_at: datetime = Field(alias="createdAt") created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt") updated_at: datetime = Field(alias="updatedAt")
@ -163,3 +177,6 @@ class NewChatRequest(BaseModel):
mentioned_document_ids: list[int] | None = ( mentioned_document_ids: list[int] | None = (
None # Optional document IDs mentioned with @ in the chat None # Optional document IDs mentioned with @ in the chat
) )
mentioned_surfsense_doc_ids: list[int] | None = (
None # Optional SurfSense documentation IDs mentioned with @ in the chat
)

View file

@ -2,6 +2,8 @@
Schemas for Surfsense documentation. Schemas for Surfsense documentation.
""" """
from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -14,6 +16,19 @@ class SurfsenseDocsChunkRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class SurfsenseDocsDocumentRead(BaseModel):
"""Schema for a Surfsense docs document (without chunks)."""
id: int
title: str
source: str
content: str
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class SurfsenseDocsDocumentWithChunksRead(BaseModel): class SurfsenseDocsDocumentWithChunksRead(BaseModel):
"""Schema for a Surfsense docs document with its chunks.""" """Schema for a Surfsense docs document with its chunks."""

View file

@ -25,7 +25,7 @@ from app.agents.new_chat.llm_config import (
load_agent_config, load_agent_config,
load_llm_config_from_yaml, load_llm_config_from_yaml,
) )
from app.db import Document from app.db import Document, SurfsenseDocsDocument
from app.schemas.new_chat import ChatAttachment from app.schemas.new_chat import ChatAttachment
from app.services.connector_service import ConnectorService from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService from app.services.new_streaming_service import VercelStreamingService
@ -69,6 +69,57 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
return "\n".join(context_parts) return "\n".join(context_parts)
def format_mentioned_surfsense_docs_as_context(
documents: list[SurfsenseDocsDocument],
) -> str:
"""Format mentioned SurfSense documentation as context for the agent."""
if not documents:
return ""
import json
context_parts = ["<mentioned_surfsense_docs>"]
context_parts.append(
"The user has explicitly mentioned the following SurfSense documentation pages. "
"These are official documentation about how to use SurfSense and should be used to answer questions about the application. "
"Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])."
)
for doc in documents:
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False)
context_parts.append("<document>")
context_parts.append("<document_metadata>")
context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>")
context_parts.append(
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
)
context_parts.append("</document_metadata>")
context_parts.append("")
context_parts.append("<document_content>")
if hasattr(doc, "chunks") and doc.chunks:
for chunk in doc.chunks:
context_parts.append(
f" <chunk id='doc-{chunk.id}'><![CDATA[{chunk.content}]]></chunk>"
)
else:
context_parts.append(
f" <chunk id='doc-0'><![CDATA[{doc.content}]]></chunk>"
)
context_parts.append("</document_content>")
context_parts.append("</document>")
context_parts.append("")
context_parts.append("</mentioned_surfsense_docs>")
return "\n".join(context_parts)
def extract_todos_from_deepagents(command_output) -> dict: def extract_todos_from_deepagents(command_output) -> dict:
""" """
Extract todos from deepagents' TodoListMiddleware Command output. Extract todos from deepagents' TodoListMiddleware Command output.
@ -101,6 +152,7 @@ async def stream_new_chat(
llm_config_id: int = -1, llm_config_id: int = -1,
attachments: list[ChatAttachment] | None = None, attachments: list[ChatAttachment] | None = None,
mentioned_document_ids: list[int] | None = None, mentioned_document_ids: list[int] | None = None,
mentioned_surfsense_doc_ids: list[int] | None = None,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
Stream chat responses from the new SurfSense deep agent. Stream chat responses from the new SurfSense deep agent.
@ -118,6 +170,7 @@ async def stream_new_chat(
messages: Optional chat history from frontend (list of ChatMessage) messages: Optional chat history from frontend (list of ChatMessage)
attachments: Optional attachments with extracted content attachments: Optional attachments with extracted content
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat 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
Yields: Yields:
str: SSE formatted response strings str: SSE formatted response strings
@ -208,7 +261,21 @@ async def stream_new_chat(
) )
mentioned_documents = list(result.scalars().all()) mentioned_documents = list(result.scalars().all())
# Format the user query with context (attachments + mentioned documents) # Fetch mentioned SurfSense docs if any
mentioned_surfsense_docs: list[SurfsenseDocsDocument] = []
if mentioned_surfsense_doc_ids:
from sqlalchemy.orm import selectinload
result = await session.execute(
select(SurfsenseDocsDocument)
.options(selectinload(SurfsenseDocsDocument.chunks))
.filter(
SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids),
)
)
mentioned_surfsense_docs = list(result.scalars().all())
# Format the user query with context (attachments + mentioned documents + surfsense docs)
final_query = user_query final_query = user_query
context_parts = [] context_parts = []
@ -220,6 +287,11 @@ async def stream_new_chat(
format_mentioned_documents_as_context(mentioned_documents) format_mentioned_documents_as_context(mentioned_documents)
) )
if mentioned_surfsense_docs:
context_parts.append(
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
)
if context_parts: if context_parts:
context = "\n\n".join(context_parts) context = "\n\n".join(context_parts)
final_query = f"{context}\n\n<user_query>{user_query}</user_query>" final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
@ -296,13 +368,13 @@ async def stream_new_chat(
last_active_step_id = analyze_step_id last_active_step_id = analyze_step_id
# Determine step title and action verb based on context # Determine step title and action verb based on context
if attachments and mentioned_documents: if attachments and (mentioned_documents or mentioned_surfsense_docs):
last_active_step_title = "Analyzing your content" last_active_step_title = "Analyzing your content"
action_verb = "Reading" action_verb = "Reading"
elif attachments: elif attachments:
last_active_step_title = "Reading your content" last_active_step_title = "Reading your content"
action_verb = "Reading" action_verb = "Reading"
elif mentioned_documents: elif mentioned_documents or mentioned_surfsense_docs:
last_active_step_title = "Analyzing referenced content" last_active_step_title = "Analyzing referenced content"
action_verb = "Analyzing" action_verb = "Analyzing"
else: else:
@ -342,6 +414,19 @@ async def stream_new_chat(
else: else:
processing_parts.append(f"[{len(doc_names)} documents]") processing_parts.append(f"[{len(doc_names)} documents]")
# Add mentioned SurfSense docs inline
if mentioned_surfsense_docs:
doc_names = []
for doc in mentioned_surfsense_docs:
title = doc.title
if len(title) > 30:
title = title[:27] + "..."
doc_names.append(title)
if len(doc_names) == 1:
processing_parts.append(f"[📖 {doc_names[0]}]")
else:
processing_parts.append(f"[📖 {len(doc_names)} docs]")
last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"] last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"]
yield streaming_service.format_thinking_step( yield streaming_service.format_thinking_step(

View file

@ -47,7 +47,7 @@ export function DocumentsFilters({
columnVisibility, columnVisibility,
onToggleColumn, onToggleColumn,
}: { }: {
typeCounts: Record<DocumentTypeEnum, number>; typeCounts: Partial<Record<DocumentTypeEnum, number>>;
selectedIds: Set<number>; selectedIds: Set<number>;
onSearch: (v: string) => void; onSearch: (v: string) => void;
searchValue: string; searchValue: string;

View file

@ -79,17 +79,25 @@ export function DocumentsTableShell({
[documents, sortKey, sortDesc] [documents, sortKey, sortDesc]
); );
const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); // Filter out SURFSENSE_DOCS for selection purposes
const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; const selectableDocs = React.useMemo(
() => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"),
[sorted]
);
const allSelectedOnPage =
selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id));
const someSelectedOnPage =
selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
const toggleAll = (checked: boolean) => { const toggleAll = (checked: boolean) => {
const next = new Set(selectedIds); const next = new Set(selectedIds);
if (checked) if (checked)
sorted.forEach((d) => { selectableDocs.forEach((d) => {
next.add(d.id); next.add(d.id);
}); });
else else
sorted.forEach((d) => { selectableDocs.forEach((d) => {
next.delete(d.id); next.delete(d.id);
}); });
setSelectedIds(next); setSelectedIds(next);
@ -230,9 +238,10 @@ export function DocumentsTableShell({
const icon = getDocumentTypeIcon(doc.document_type); const icon = getDocumentTypeIcon(doc.document_type);
const title = doc.title; const title = doc.title;
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS";
return ( return (
<motion.tr <motion.tr
key={doc.id} key={`${doc.document_type}-${doc.id}`}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ animate={{
opacity: 1, opacity: 1,
@ -249,8 +258,9 @@ export function DocumentsTableShell({
> >
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">
<Checkbox <Checkbox
checked={selectedIds.has(doc.id)} checked={selectedIds.has(doc.id) && !isSurfsenseDoc}
onCheckedChange={(v) => toggleOne(doc.id, !!v)} onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)}
disabled={isSurfsenseDoc}
aria-label="Select row" aria-label="Select row"
/> />
</TableCell> </TableCell>

View file

@ -28,6 +28,9 @@ import type { Document } from "./types";
// Only FILE and NOTE document types can be edited // Only FILE and NOTE document types can be edited
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
// SURFSENSE_DOCS are system-managed and cannot be deleted
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
export function RowActions({ export function RowActions({
document, document,
deleteDocument, deleteDocument,
@ -48,6 +51,10 @@ export function RowActions({
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
); );
const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes(
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
);
const handleDelete = async () => { const handleDelete = async () => {
setIsDeleting(true); setIsDeleting(true);
try { try {
@ -120,6 +127,7 @@ export function RowActions({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{isDeletable && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<motion.div <motion.div
@ -143,6 +151,7 @@ export function RowActions({
<p>Delete</p> <p>Delete</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
</div> </div>
{/* Mobile Actions Dropdown */} {/* Mobile Actions Dropdown */}
@ -165,6 +174,7 @@ export function RowActions({
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
<span>Metadata</span> <span>Metadata</span>
</DropdownMenuItem> </DropdownMenuItem>
{isDeletable && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setIsDeleteOpen(true)} onClick={() => setIsDeleteOpen(true)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
@ -172,6 +182,7 @@ export function RowActions({
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span> <span>Delete</span>
</DropdownMenuItem> </DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View file

@ -2,14 +2,15 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { RefreshCw } from "lucide-react"; import { RefreshCw, SquarePlus, Upload } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useLogsSummary } from "@/hooks/use-logs"; import { useLogsSummary } from "@/hooks/use-logs";
@ -19,7 +20,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters";
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
import { PaginationControls } from "./components/PaginationControls"; import { PaginationControls } from "./components/PaginationControls";
import { ProcessingIndicator } from "./components/ProcessingIndicator"; import { ProcessingIndicator } from "./components/ProcessingIndicator";
import type { ColumnVisibility } from "./components/types"; import type { ColumnVisibility, Document } from "./components/types";
function useDebounced<T>(value: T, delay = 250) { function useDebounced<T>(value: T, delay = 250) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
@ -34,7 +35,13 @@ export default function DocumentsTable() {
const t = useTranslations("documents"); const t = useTranslations("documents");
const id = useId(); const id = useId();
const params = useParams(); const params = useParams();
const router = useRouter();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const handleNewNote = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/editor/new`);
}, [router, searchSpaceId]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 250); const debouncedSearch = useDebounced(search, 250);
@ -50,33 +57,42 @@ export default function DocumentsTable() {
const [sortKey, setSortKey] = useState<SortKey>("title"); const [sortKey, setSortKey] = useState<SortKey>("title");
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const { data: typeCounts } = useAtomValue(documentTypeCountsAtom); const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
// Build query parameters for fetching documents // Filter out SURFSENSE_DOCS from active types for regular documents API
const regularDocumentTypes = useMemo(
() => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"),
[activeTypes]
);
// Check if only SURFSENSE_DOCS is selected (skip regular docs query)
const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: pageSize,
...(activeTypes.length > 0 && { document_types: activeTypes }), ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, activeTypes] [searchSpaceId, pageIndex, pageSize, regularDocumentTypes]
); );
// Build search query parameters // Build search query parameters (excluding SURFSENSE_DOCS type)
const searchQueryParams = useMemo( const searchQueryParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: pageSize,
title: debouncedSearch.trim(), title: debouncedSearch.trim(),
...(activeTypes.length > 0 && { document_types: activeTypes }), ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] [searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch]
); );
// Use query for fetching documents // Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected)
const { const {
data: documentsResponse, data: documentsResponse,
isLoading: isDocumentsLoading, isLoading: isDocumentsLoading,
@ -86,10 +102,10 @@ export default function DocumentsTable() {
queryKey: cacheKeys.documents.globalQueryParams(queryParams), queryKey: cacheKeys.documents.globalQueryParams(queryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams }), queryFn: () => documentsApiService.getDocuments({ queryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !debouncedSearch.trim(), enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected,
}); });
// Use query for searching documents // Use query for searching documents (disabled when only SURFSENSE_DOCS is selected)
const { const {
data: searchResponse, data: searchResponse,
isLoading: isSearchLoading, isLoading: isSearchLoading,
@ -99,16 +115,111 @@ export default function DocumentsTable() {
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !!debouncedSearch.trim(), enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected,
}); });
// Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected)
const showSurfsenseDocs =
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
// Use query for fetching SurfSense docs
const {
data: surfsenseDocsResponse,
isLoading: isSurfsenseDocsLoading,
refetch: refetchSurfsenseDocs,
} = useQuery({
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
queryFn: () =>
documentsApiService.getSurfsenseDocs({
queryParams: {
page: pageIndex,
page_size: pageSize,
title: debouncedSearch.trim() || undefined,
},
}),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: showSurfsenseDocs,
});
// Transform SurfSense docs to match the Document type
const surfsenseDocsAsDocuments: Document[] = useMemo(() => {
if (!surfsenseDocsResponse?.items) return [];
return surfsenseDocsResponse.items.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: "SURFSENSE_DOCS",
document_metadata: { source: doc.source },
content: doc.content,
created_at: doc.created_at || doc.updated_at || new Date().toISOString(),
search_space_id: -1, // Special value for global docs
}));
}, [surfsenseDocsResponse]);
// Merge type counts with SURFSENSE_DOCS count
const typeCounts = useMemo(() => {
const counts = { ...(rawTypeCounts || {}) };
if (surfsenseDocsResponse?.total) {
counts.SURFSENSE_DOCS = surfsenseDocsResponse.total;
}
return counts;
}, [rawTypeCounts, surfsenseDocsResponse?.total]);
// Extract documents and total based on search state // Extract documents and total based on search state
const documents = debouncedSearch.trim() const regularDocuments = debouncedSearch.trim()
? searchResponse?.items || [] ? searchResponse?.items || []
: documentsResponse?.items || []; : documentsResponse?.items || [];
const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0; const regularTotal = debouncedSearch.trim()
const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; ? searchResponse?.total || 0
const error = debouncedSearch.trim() ? searchError : documentsError; : documentsResponse?.total || 0;
// Merge regular documents with SurfSense docs
const documents = useMemo(() => {
// If filtering by type and not including SURFSENSE_DOCS, only show regular docs
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
return regularDocuments;
}
// If filtering only by SURFSENSE_DOCS, only show surfsense docs
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
return surfsenseDocsAsDocuments;
}
// Otherwise, merge both (surfsense docs first)
return [...surfsenseDocsAsDocuments, ...regularDocuments];
}, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]);
const total = useMemo(() => {
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
return regularTotal;
}
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
return surfsenseDocsResponse?.total || 0;
}
return regularTotal + (surfsenseDocsResponse?.total || 0);
}, [regularTotal, surfsenseDocsResponse?.total, activeTypes]);
const loading = useMemo(() => {
// If only SURFSENSE_DOCS selected, only check surfsense loading
if (onlySurfsenseDocsSelected) {
return isSurfsenseDocsLoading;
}
// Otherwise check both regular docs and surfsense docs loading
const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading);
}, [
onlySurfsenseDocsSelected,
isSurfsenseDocsLoading,
debouncedSearch,
isSearchLoading,
isDocumentsLoading,
showSurfsenseDocs,
]);
const error = useMemo(() => {
// If only SURFSENSE_DOCS selected, no regular docs errors
if (onlySurfsenseDocsSelected) {
return null;
}
return debouncedSearch.trim() ? searchError : documentsError;
}, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]);
// Display server-filtered results directly // Display server-filtered results directly
const displayDocs = documents || []; const displayDocs = documents || [];
@ -131,16 +242,33 @@ export default function DocumentsTable() {
if (isRefreshing) return; if (isRefreshing) return;
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const refetchPromises: Promise<unknown>[] = [];
// Only refetch regular documents if not in "only surfsense docs" mode
if (!onlySurfsenseDocsSelected) {
if (debouncedSearch.trim()) { if (debouncedSearch.trim()) {
await refetchSearch(); refetchPromises.push(refetchSearch());
} else { } else {
await refetchDocuments(); refetchPromises.push(refetchDocuments());
} }
}
if (showSurfsenseDocs) {
refetchPromises.push(refetchSurfsenseDocs());
}
await Promise.all(refetchPromises);
toast.success(t("refresh_success") || "Documents refreshed"); toast.success(t("refresh_success") || "Documents refreshed");
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]); }, [
debouncedSearch,
refetchSearch,
refetchDocuments,
refetchSurfsenseDocs,
showSurfsenseDocs,
onlySurfsenseDocsSelected,
t,
isRefreshing,
]);
// Set up smart polling for active tasks - only polls when tasks are in progress // Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, { const { summary } = useLogsSummary(searchSpaceId, 24, {
@ -238,10 +366,20 @@ export default function DocumentsTable() {
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2> <h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p> <p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div> </div>
<div className="flex items-center gap-2">
<Button onClick={openUploadDialog} variant="default" size="sm">
<Upload className="w-4 h-4 mr-2" />
{t("upload_documents")}
</Button>
<Button onClick={handleNewNote} variant="outline" size="sm">
<SquarePlus className="w-4 h-4 mr-2" />
{t("create_shared_note")}
</Button>
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}> <Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")} {t("refresh")}
</Button> </Button>
</div>
</motion.div> </motion.div>
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} /> <ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />

View file

@ -267,21 +267,8 @@ export default function EditorPage() {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background..."); toast.success("Note created successfully! Reindexing in background...");
// Invalidate notes query to refresh the sidebar // Redirect to documents page after successful save
queryClient.invalidateQueries({ router.push(`/dashboard/${searchSpaceId}/documents`);
queryKey: ["notes", String(searchSpaceId)],
});
// Update URL to reflect the new document ID without navigation
window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`);
// Update document state to reflect the new ID
setDocument({
document_id: note.id,
title: title,
document_type: "NOTE",
blocknote_document: editorContent,
updated_at: new Date().toISOString(),
});
} else { } else {
// Existing document - save normally // Existing document - save normally
if (!editorContent) { if (!editorContent) {
@ -310,12 +297,8 @@ export default function EditorPage() {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background..."); toast.success("Document saved! Reindexing in background...");
// Invalidate notes query when updating notes to refresh the sidebar // Redirect to documents page after successful save
if (isNote) { router.push(`/dashboard/${searchSpaceId}/documents`);
queryClient.invalidateQueries({
queryKey: ["notes", String(searchSpaceId)],
});
}
} }
} catch (error) { } catch (error) {
console.error("Error saving document:", error); console.error("Error saving document:", error);
@ -336,7 +319,7 @@ export default function EditorPage() {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
setShowUnsavedDialog(true); setShowUnsavedDialog(true);
} else { } else {
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/documents`);
} }
}; };
@ -346,12 +329,12 @@ export default function EditorPage() {
setGlobalHasUnsavedChanges(false); setGlobalHasUnsavedChanges(false);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
// If there's a pending navigation (from sidebar), use that; otherwise go back to chat // If there's a pending navigation (from sidebar), use that; otherwise go back to documents
if (pendingNavigation) { if (pendingNavigation) {
router.push(pendingNavigation); router.push(pendingNavigation);
setPendingNavigation(null); setPendingNavigation(null);
} else { } else {
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/documents`);
} }
}; };
@ -392,7 +375,7 @@ export default function EditorPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button <Button
onClick={() => router.push(`/dashboard/${searchSpaceId}/new-chat`)} onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
variant="outline" variant="outline"
className="gap-2" className="gap-2"
> >

View file

@ -40,9 +40,12 @@ import {
} from "@/lib/chat/podcast-state"; } from "@/lib/chat/podcast-state";
import { import {
appendMessage, appendMessage,
type ChatVisibility,
createThread, createThread,
getThreadFull,
getThreadMessages, getThreadMessages,
type MessageRecord, type MessageRecord,
type ThreadRecord,
} from "@/lib/chat/thread-persistence"; } from "@/lib/chat/thread-persistence";
import { import {
trackChatCreated, trackChatCreated,
@ -217,6 +220,7 @@ export default function NewChatPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const [threadId, setThreadId] = useState<number | null>(null); const [threadId, setThreadId] = useState<number | null>(null);
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]); const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
// Store thinking steps per message ID - kept separate from content to avoid // Store thinking steps per message ID - kept separate from content to avoid
@ -264,19 +268,31 @@ export default function NewChatPage() {
// Reset all state when switching between chats to prevent stale data // Reset all state when switching between chats to prevent stale data
setMessages([]); setMessages([]);
setThreadId(null); setThreadId(null);
setCurrentThread(null);
setMessageThinkingSteps(new Map()); setMessageThinkingSteps(new Map());
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
setMessageDocumentsMap({}); setMessageDocumentsMap({});
clearPlanOwnerRegistry(); // Reset plan ownership for new chat clearPlanOwnerRegistry(); // Reset plan ownership for new chat
try { try {
if (urlChatId > 0) { if (urlChatId > 0) {
// Thread exists - load messages // Thread exists - load thread data and messages
setThreadId(urlChatId); setThreadId(urlChatId);
const response = await getThreadMessages(urlChatId);
if (response.messages && response.messages.length > 0) { // Load thread data (for visibility info) and messages in parallel
const loadedMessages = response.messages.map(convertToThreadMessage); const [threadData, messagesResponse] = await Promise.all([
getThreadFull(urlChatId),
getThreadMessages(urlChatId),
]);
setCurrentThread(threadData);
if (messagesResponse.messages && messagesResponse.messages.length > 0) {
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage);
setMessages(loadedMessages); setMessages(loadedMessages);
// Extract and restore thinking steps from persisted messages // Extract and restore thinking steps from persisted messages
@ -284,7 +300,7 @@ export default function NewChatPage() {
// Extract and restore mentioned documents from persisted messages // Extract and restore mentioned documents from persisted messages
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {}; const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
for (const msg of response.messages) { for (const msg of messagesResponse.messages) {
if (msg.role === "assistant") { if (msg.role === "assistant") {
const steps = extractThinkingSteps(msg.content); const steps = extractThinkingSteps(msg.content);
if (steps.length > 0) { if (steps.length > 0) {
@ -320,6 +336,7 @@ export default function NewChatPage() {
// Keep threadId as null - don't use Date.now() as it creates an invalid ID // Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls // that will cause 404 errors on subsequent API calls
setThreadId(null); setThreadId(null);
setCurrentThread(null);
toast.error("Failed to load chat. Please try again."); toast.error("Failed to load chat. Please try again.");
} finally { } finally {
setIsInitializing(false); setIsInitializing(false);
@ -346,6 +363,19 @@ export default function NewChatPage() {
setIsRunning(false); setIsRunning(false);
}, []); }, []);
// Handle visibility change from ChatShareButton
const handleVisibilityChange = useCallback(
(newVisibility: ChatVisibility) => {
setCurrentThread((prev) => (prev ? { ...prev, visibility: newVisibility } : null));
// Refetch all thread queries so sidebar reflects the change immediately
// Use predicate to match any query that starts with "threads"
queryClient.refetchQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
});
},
[queryClient]
);
// Handle new message from user // Handle new message from user
const onNew = useCallback( const onNew = useCallback(
async (message: AppendMessage) => { async (message: AppendMessage) => {
@ -429,7 +459,9 @@ export default function NewChatPage() {
// Track message sent // Track message sent
trackChatMessageSent(searchSpaceId, currentThreadId, { trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: messageAttachments.length > 0, hasAttachments: messageAttachments.length > 0,
hasMentionedDocuments: mentionedDocumentIds.length > 0, hasMentionedDocuments:
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
mentionedDocumentIds.document_ids.length > 0,
messageLength: userQuery.length, messageLength: userQuery.length,
}); });
@ -627,12 +659,16 @@ export default function NewChatPage() {
// Extract attachment content to send with the request // Extract attachment content to send with the request
const attachments = extractAttachmentContent(messageAttachments); const attachments = extractAttachmentContent(messageAttachments);
// Get mentioned document IDs for context // Get mentioned document IDs for context (separate fields for backend)
const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined; const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
// Clear mentioned documents after capturing them // Clear mentioned documents after capturing them
if (mentionedDocumentIds.length > 0) { if (hasDocumentIds || hasSurfsenseDocIds) {
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
} }
@ -648,7 +684,10 @@ export default function NewChatPage() {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
messages: messageHistory, messages: messageHistory,
attachments: attachments.length > 0 ? attachments : undefined, attachments: attachments.length > 0 ? attachments : undefined,
mentioned_document_ids: documentIds, mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
? mentionedDocumentIds.surfsense_doc_ids
: undefined,
}), }),
signal: controller.signal, signal: controller.signal,
}); });
@ -916,7 +955,13 @@ export default function NewChatPage() {
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden"> <div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
<Thread <Thread
messageThinkingSteps={messageThinkingSteps} messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />} header={
<ChatHeader
searchSpaceId={searchSpaceId}
thread={currentThread}
onThreadVisibilityChange={handleVisibilityChange}
/>
}
/> />
</div> </div>
</AssistantRuntimeProvider> </AssistantRuntimeProvider>

View file

@ -13,6 +13,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager";
@ -23,28 +24,28 @@ import { cn } from "@/lib/utils";
interface SettingsNavItem { interface SettingsNavItem {
id: string; id: string;
label: string; labelKey: string;
description: string; descriptionKey: string;
icon: LucideIcon; icon: LucideIcon;
} }
const settingsNavItems: SettingsNavItem[] = [ const settingsNavItems: SettingsNavItem[] = [
{ {
id: "models", id: "models",
label: "Agent Configs", labelKey: "nav_agent_configs",
description: "LLM models with prompts & citations", descriptionKey: "nav_agent_configs_desc",
icon: Bot, icon: Bot,
}, },
{ {
id: "roles", id: "roles",
label: "Role Assignments", labelKey: "nav_role_assignments",
description: "Assign configs to agent roles", descriptionKey: "nav_role_assignments_desc",
icon: Brain, icon: Brain,
}, },
{ {
id: "prompts", id: "prompts",
label: "System Instructions", labelKey: "nav_system_instructions",
description: "SearchSpace-wide AI instructions", descriptionKey: "nav_system_instructions_desc",
icon: MessageSquare, icon: MessageSquare,
}, },
]; ];
@ -62,6 +63,8 @@ function SettingsSidebar({
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const t = useTranslations("searchSpaceSettings");
const handleNavClick = (sectionId: string) => { const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId); onSectionChange(sectionId);
onClose(); // Close sidebar on mobile after selection onClose(); // Close sidebar on mobile after selection
@ -94,23 +97,29 @@ function SettingsSidebar({
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
{/* Header with back button */} {/* Header with title */}
<div className="p-4 flex items-center justify-between"> <div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<Button <Button
variant="ghost" variant="ghost"
onClick={onBackToApp} onClick={onBackToApp}
className="flex-1 justify-start gap-3 h-11 px-3 hover:bg-muted group" className="justify-start gap-3 h-11 px-3 hover:bg-muted group"
> >
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
<ArrowLeft className="h-4 w-4 text-primary" /> <ArrowLeft className="h-4 w-4 text-primary" />
</div> </div>
<span className="font-medium">Back to app</span> <span className="font-medium">{t("back_to_app")}</span>
</Button> </Button>
{/* Mobile close button */} {/* Mobile close button */}
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9"> <Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</Button> </Button>
</div> </div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
{/* Navigation Items */} {/* Navigation Items */}
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto"> <nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
@ -159,9 +168,11 @@ function SettingsSidebar({
isActive ? "text-foreground" : "text-muted-foreground" isActive ? "text-foreground" : "text-muted-foreground"
)} )}
> >
{item.label} {t(item.labelKey)}
</p>
<p className="text-xs text-muted-foreground/70 truncate">
{t(item.descriptionKey)}
</p> </p>
<p className="text-xs text-muted-foreground/70 truncate">{item.description}</p>
</div> </div>
<ChevronRight <ChevronRight
className={cn( className={cn(
@ -175,11 +186,6 @@ function SettingsSidebar({
); );
})} })}
</nav> </nav>
{/* Footer */}
<div className="p-4">
<p className="text-xs text-muted-foreground text-center">Search Space Settings</p>
</div>
</aside> </aside>
</> </>
); );
@ -194,6 +200,7 @@ function SettingsContent({
searchSpaceId: number; searchSpaceId: number;
onMenuClick: () => void; onMenuClick: () => void;
}) { }) {
const t = useTranslations("searchSpaceSettings");
const activeItem = settingsNavItems.find((item) => item.id === activeSection); const activeItem = settingsNavItems.find((item) => item.id === activeSection);
const Icon = activeItem?.icon || Settings; const Icon = activeItem?.icon || Settings;
@ -236,7 +243,7 @@ function SettingsContent({
</motion.div> </motion.div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-lg md:text-2xl font-bold tracking-tight truncate"> <h1 className="text-lg md:text-2xl font-bold tracking-tight truncate">
{activeItem?.label} {activeItem ? t(activeItem.labelKey) : ""}
</h1> </h1>
</div> </div>
</div> </div>

View file

@ -75,11 +75,13 @@ function UserSettingsSidebar({
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
<div className="flex items-center justify-between p-4"> {/* Header with title */}
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<Button <Button
variant="ghost" variant="ghost"
onClick={onBackToApp} onClick={onBackToApp}
className="group h-11 flex-1 justify-start gap-3 px-3 hover:bg-muted" className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
> >
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" /> <ArrowLeft className="h-4 w-4 text-primary" />
@ -90,6 +92,11 @@ function UserSettingsSidebar({
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</Button> </Button>
</div> </div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2"> <nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => { {navItems.map((item, index) => {
@ -153,10 +160,6 @@ function UserSettingsSidebar({
); );
})} })}
</nav> </nav>
<div className="p-4">
<p className="text-center text-xs text-muted-foreground">{t("footer")}</p>
</div>
</aside> </aside>
</> </>
); );

View file

@ -1,19 +1,25 @@
"use client"; "use client";
import { atom } from "jotai"; import { atom } from "jotai";
import type { Document } from "@/contracts/types/document.types"; import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types";
/** /**
* Atom to store the IDs of documents mentioned in the current chat composer. * Atom to store the IDs of documents mentioned in the current chat composer.
* This is used to pass document context to the backend when sending a message. * This is used to pass document context to the backend when sending a message.
*/ */
export const mentionedDocumentIdsAtom = atom<number[]>([]); export const mentionedDocumentIdsAtom = atom<{
surfsense_doc_ids: number[];
document_ids: number[];
}>({
surfsense_doc_ids: [],
document_ids: [],
});
/** /**
* Atom to store the full document objects mentioned in the current chat composer. * Atom to store the full document objects mentioned in the current chat composer.
* This persists across component remounts. * This persists across component remounts.
*/ */
export const mentionedDocumentsAtom = atom<Document[]>([]); export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
/** /**
* Simplified document info for display purposes * Simplified document info for display purposes

View file

@ -53,7 +53,14 @@ export const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request // Sync mentioned document IDs to atom for use in chat request
useEffect(() => { useEffect(() => {
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]); }, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer // Handle text change from inline editor - sync with assistant-ui composer
@ -119,7 +126,10 @@ export const Composer: FC = () => {
// Clear the editor after sending // Clear the editor after sending
editorRef.current?.clear(); editorRef.current?.clear();
setMentionedDocuments([]); setMentionedDocuments([]);
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
} }
}, [ }, [
showDocumentPopover, showDocumentPopover,
@ -129,41 +139,52 @@ export const Composer: FC = () => {
setMentionedDocumentIds, setMentionedDocumentIds,
]); ]);
// Handle document removal from inline editor
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const updated = prev.filter((doc) => doc.id !== docId); const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
// Immediately sync document IDs to avoid race conditions setMentionedDocumentIds({
setMentionedDocumentIds(updated.map((doc) => doc.id)); surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated; return updated;
}); });
}, },
[setMentionedDocuments, setMentionedDocumentIds] [setMentionedDocuments, setMentionedDocumentIds]
); );
// Handle document selection from picker
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Document[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
// Insert chips into the inline editor for each new document const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const existingIds = new Set(mentionedDocuments.map((d) => d.id)); const newDocs = documents.filter(
const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
for (const doc of newDocs) { for (const doc of newDocs) {
editorRef.current?.insertDocumentChip(doc); editorRef.current?.insertDocumentChip(doc);
} }
// Update mentioned documents state
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const existingIdSet = new Set(prev.map((d) => d.id)); const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
);
const updated = [...prev, ...uniqueNewDocs]; const updated = [...prev, ...uniqueNewDocs];
// Immediately sync document IDs to avoid race conditions setMentionedDocumentIds({
setMentionedDocumentIds(updated.map((doc) => doc.id)); surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated; return updated;
}); });
// Reset mention query but keep popover open for more selections
setMentionQuery(""); setMentionQuery("");
}, },
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]

View file

@ -86,7 +86,6 @@ const DocumentUploadPopupContent: FC<{
}> = ({ isOpen, onOpenChange }) => { }> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const router = useRouter(); const router = useRouter();
const [isAccordionExpanded, setIsAccordionExpanded] = useState(false);
if (!searchSpaceId) return null; if (!searchSpaceId) return null;
@ -118,19 +117,16 @@ const DocumentUploadPopupContent: FC<{
{/* Scrollable Content */} {/* Scrollable Content */}
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div className={`h-full ${isAccordionExpanded ? "overflow-y-auto" : ""}`}> <div className="h-full overflow-y-auto">
<div className="px-6 sm:px-12 pb-5 sm:pb-16"> <div className="px-6 sm:px-12 pb-5 sm:pb-16">
<DocumentUploadTab <DocumentUploadTab
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onSuccess={handleSuccess} onSuccess={handleSuccess}
onAccordionStateChange={setIsAccordionExpanded}
/> />
</div> </div>
</div> </div>
{/* Bottom fade shadow - only show when scrolling */} {/* Bottom fade shadow */}
{isAccordionExpanded && (
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" /> <div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -25,7 +25,7 @@ export interface InlineMentionEditorRef {
clear: () => void; clear: () => void;
getText: () => string; getText: () => string;
getMentionedDocuments: () => MentionedDocument[]; getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Document) => void; insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
} }
interface InlineMentionEditorProps { interface InlineMentionEditorProps {
@ -34,7 +34,7 @@ interface InlineMentionEditorProps {
onMentionClose?: () => void; onMentionClose?: () => void;
onSubmit?: () => void; onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void; onChange?: (text: string, docs: MentionedDocument[]) => void;
onDocumentRemove?: (docId: number) => void; onDocumentRemove?: (docId: number, docType?: string) => void;
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
@ -44,6 +44,7 @@ interface InlineMentionEditorProps {
// Unique data attribute to identify chip elements // Unique data attribute to identify chip elements
const CHIP_DATA_ATTR = "data-mention-chip"; const CHIP_DATA_ATTR = "data-mention-chip";
const CHIP_ID_ATTR = "data-mention-id"; const CHIP_ID_ATTR = "data-mention-id";
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
/** /**
* Type guard to check if a node is a chip element * Type guard to check if a node is a chip element
@ -66,6 +67,13 @@ function getChipId(element: Element): number | null {
return Number.isNaN(id) ? null : id; return Number.isNaN(id) ? null : id;
} }
/**
* Get chip document type from element attribute
*/
function getChipDocType(element: Element): string {
return element.getAttribute(CHIP_DOCTYPE_ATTR) ?? "UNKNOWN";
}
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>( export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
( (
{ {
@ -84,15 +92,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
) => { ) => {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const [isEmpty, setIsEmpty] = useState(true); const [isEmpty, setIsEmpty] = useState(true);
const [mentionedDocs, setMentionedDocs] = useState<Map<number, MentionedDocument>>( const [mentionedDocs, setMentionedDocs] = useState<Map<string, MentionedDocument>>(
() => new Map(initialDocuments.map((d) => [d.id, d])) () => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
); );
const isComposingRef = useRef(false); const isComposingRef = useRef(false);
// Sync initial documents // Sync initial documents
useEffect(() => { useEffect(() => {
if (initialDocuments.length > 0) { if (initialDocuments.length > 0) {
setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d]))); setMentionedDocs(
new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
);
} }
}, [initialDocuments]); }, [initialDocuments]);
@ -153,6 +163,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const chip = document.createElement("span"); const chip = document.createElement("span");
chip.setAttribute(CHIP_DATA_ATTR, "true"); chip.setAttribute(CHIP_DATA_ATTR, "true");
chip.setAttribute(CHIP_ID_ATTR, String(doc.id)); chip.setAttribute(CHIP_ID_ATTR, String(doc.id));
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
chip.contentEditable = "false"; chip.contentEditable = "false";
chip.className = chip.className =
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none"; "inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
@ -175,13 +186,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
chip.remove(); chip.remove();
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
setMentionedDocs((prev) => { setMentionedDocs((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(doc.id); next.delete(docKey);
return next; return next;
}); });
// Notify parent that a document was removed // Notify parent that a document was removed
onDocumentRemove?.(doc.id); onDocumentRemove?.(doc.id, doc.document_type);
focusAtEnd(); focusAtEnd();
}; };
@ -195,7 +207,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Insert a document chip at the current cursor position // Insert a document chip at the current cursor position
const insertDocumentChip = useCallback( const insertDocumentChip = useCallback(
(doc: Document) => { (doc: Pick<Document, "id" | "title" | "document_type">) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Validate required fields for type safety // Validate required fields for type safety
@ -210,8 +222,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
document_type: doc.document_type, document_type: doc.document_type,
}; };
// Add to mentioned docs map // Add to mentioned docs map using unique key
setMentionedDocs((prev) => new Map(prev).set(doc.id, mentionDoc)); const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc));
// Find and remove the @query text // Find and remove the @query text
const selection = window.getSelection(); const selection = window.getSelection();
@ -413,15 +426,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (isChipElement(prevSibling)) { if (isChipElement(prevSibling)) {
e.preventDefault(); e.preventDefault();
const chipId = getChipId(prevSibling); const chipId = getChipId(prevSibling);
const chipDocType = getChipDocType(prevSibling);
if (chipId !== null) { if (chipId !== null) {
prevSibling.remove(); prevSibling.remove();
const chipKey = `${chipDocType}:${chipId}`;
setMentionedDocs((prev) => { setMentionedDocs((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(chipId); next.delete(chipKey);
return next; return next;
}); });
// Notify parent that a document was removed // Notify parent that a document was removed
onDocumentRemove?.(chipId); onDocumentRemove?.(chipId, chipDocType);
} }
return; return;
} }
@ -448,15 +463,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (isChipElement(prevChild)) { if (isChipElement(prevChild)) {
e.preventDefault(); e.preventDefault();
const chipId = getChipId(prevChild); const chipId = getChipId(prevChild);
const chipDocType = getChipDocType(prevChild);
if (chipId !== null) { if (chipId !== null) {
prevChild.remove(); prevChild.remove();
const chipKey = `${chipDocType}:${chipId}`;
setMentionedDocs((prev) => { setMentionedDocs((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(chipId); next.delete(chipKey);
return next; return next;
}); });
// Notify parent that a document was removed // Notify parent that a document was removed
onDocumentRemove?.(chipId); onDocumentRemove?.(chipId, chipDocType);
} }
} }
} }

View file

@ -16,7 +16,8 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g; // Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts
const CITATION_REGEX = /[[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering // Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering // This map is reset when a new message starts rendering
@ -90,10 +91,6 @@ function parseTextWithCitations(text: string): ReactNode[] {
} }
const MarkdownTextImpl = () => { const MarkdownTextImpl = () => {
// Reset citation counter at the start of each render
// This ensures consistent numbering as the message streams in
resetCitationCounter();
return ( return (
<MarkdownTextPrimitive <MarkdownTextPrimitive
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}

View file

@ -229,7 +229,14 @@ const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request // Sync mentioned document IDs to atom for use in chat request
useEffect(() => { useEffect(() => {
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]); }, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer // Handle text change from inline editor - sync with assistant-ui composer
@ -295,7 +302,10 @@ const Composer: FC = () => {
// Clear the editor after sending // Clear the editor after sending
editorRef.current?.clear(); editorRef.current?.clear();
setMentionedDocuments([]); setMentionedDocuments([]);
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
} }
}, [ }, [
showDocumentPopover, showDocumentPopover,
@ -305,41 +315,52 @@ const Composer: FC = () => {
setMentionedDocumentIds, setMentionedDocumentIds,
]); ]);
// Handle document removal from inline editor
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const updated = prev.filter((doc) => doc.id !== docId); const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
// Immediately sync document IDs to avoid race conditions setMentionedDocumentIds({
setMentionedDocumentIds(updated.map((doc) => doc.id)); surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated; return updated;
}); });
}, },
[setMentionedDocuments, setMentionedDocumentIds] [setMentionedDocuments, setMentionedDocumentIds]
); );
// Handle document selection from picker
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Document[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
// Insert chips into the inline editor for each new document const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const existingIds = new Set(mentionedDocuments.map((d) => d.id)); const newDocs = documents.filter(
const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
for (const doc of newDocs) { for (const doc of newDocs) {
editorRef.current?.insertDocumentChip(doc); editorRef.current?.insertDocumentChip(doc);
} }
// Update mentioned documents state
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const existingIdSet = new Set(prev.map((d) => d.id)); const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
);
const updated = [...prev, ...uniqueNewDocs]; const updated = [...prev, ...uniqueNewDocs];
// Immediately sync document IDs to avoid race conditions setMentionedDocumentIds({
setMentionedDocumentIds(updated.map((doc) => doc.id)); surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated; return updated;
}); });
// Reset mention query but keep popover open for more selections
setMentionQuery(""); setMentionQuery("");
}, },
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
@ -640,7 +661,7 @@ const UserMessage: FC = () => {
{/* Mentioned documents as chips */} {/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => ( {mentionedDocs?.map((doc) => (
<span <span
key={doc.id} key={`${doc.document_type}:${doc.id}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
title={doc.title} title={doc.title}
> >

View file

@ -29,7 +29,7 @@ export const UserMessage: FC = () => {
{/* Mentioned documents as chips */} {/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => ( {mentionedDocs?.map((doc) => (
<span <span
key={doc.id} key={`${doc.document_type}:${doc.id}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
title={doc.title} title={doc.title}
> >

View file

@ -4,14 +4,12 @@ export type {
ChatItem, ChatItem,
IconRailProps, IconRailProps,
NavItem, NavItem,
NoteItem,
PageUsage, PageUsage,
SearchSpace, SearchSpace,
SidebarSectionProps, SidebarSectionProps,
User, User,
} from "./types/layout.types"; } from "./types/layout.types";
export { export {
AllSearchSpacesSheet,
ChatListItem, ChatListItem,
CreateSearchSpaceDialog, CreateSearchSpaceDialog,
Header, Header,
@ -21,7 +19,6 @@ export {
MobileSidebarTrigger, MobileSidebarTrigger,
NavIcon, NavIcon,
NavSection, NavSection,
NoteListItem,
PageUsageDisplay, PageUsageDisplay,
SearchSpaceAvatar, SearchSpaceAvatar,
Sidebar, Sidebar,

View file

@ -1,13 +1,12 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue } from "jotai";
import { Logs, SquareLibrary, Trash2 } from "lucide-react"; import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
@ -20,18 +19,15 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useLogsSummary } from "@/hooks/use-logs";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { resetUser, trackLogout } from "@/lib/posthog/events"; import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types"; import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { AllSearchSpacesSheet } from "../ui/sheets";
import { LayoutShell } from "../ui/shell"; import { LayoutShell } from "../ui/shell";
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
interface LayoutDataProviderProps { interface LayoutDataProviderProps {
searchSpaceId: string; searchSpaceId: string;
@ -58,16 +54,11 @@ export function LayoutDataProvider({
const { data: user } = useAtomValue(currentUserAtom); const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
// Current IDs from URL // Current IDs from URL
const currentChatId = params?.chat_id const currentChatId = params?.chat_id
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null; : null;
const currentNoteId = params?.note_id
? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id)
: null;
// Fetch current search space // Fetch current search space
const { data: searchSpace } = useQuery({ const { data: searchSpace } = useQuery({
@ -77,45 +68,17 @@ export function LayoutDataProvider({
}); });
// Fetch threads // Fetch threads
const { data: threadsData, refetch: refetchThreads } = useQuery({ const { data: threadsData } = useQuery({
queryKey: ["threads", searchSpaceId, { limit: 4 }], queryKey: ["threads", searchSpaceId, { limit: 4 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 4), queryFn: () => fetchThreads(Number(searchSpaceId), 4),
enabled: !!searchSpaceId, enabled: !!searchSpaceId,
}); });
// Fetch notes // Separate sidebar states for shared and private chats
const { data: notesData, refetch: refetchNotes } = useQuery({ const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
queryKey: ["notes", searchSpaceId], const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
page_size: 4,
}),
enabled: !!searchSpaceId,
});
// Poll for active reindexing tasks to show inline loading indicators // Search space dialog state
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
enablePolling: true,
refetchInterval: 5000,
});
// Create a Set of document IDs that are currently being reindexed
const reindexingDocumentIds = useMemo(() => {
if (!summary?.active_tasks) return new Set<number>();
return new Set(
summary.active_tasks
.filter((task) => task.document_id != null)
.map((task) => task.document_id as number)
);
}, [summary?.active_tasks]);
// All chats/notes sidebars state
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
// Search space sheet and dialog state
const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false);
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Delete dialogs state // Delete dialogs state
@ -123,13 +86,13 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false); const [isDeletingChat, setIsDeletingChat] = useState(false);
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false); // Delete/Leave search space dialog state
const [noteToDelete, setNoteToDelete] = useState<{ const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
id: number; const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
name: string; const [searchSpaceToDelete, setSearchSpaceToDelete] = useState<SearchSpace | null>(null);
search_space_id: number; const [searchSpaceToLeave, setSearchSpaceToLeave] = useState<SearchSpace | null>(null);
} | null>(null); const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
const [isDeletingNote, setIsDeletingNote] = useState(false); const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
const searchSpaces: SearchSpace[] = useMemo(() => { const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
@ -149,35 +112,34 @@ export function LayoutDataProvider({
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null; return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
}, [searchSpaceId, searchSpaces]); }, [searchSpaceId, searchSpaces]);
// Transform chats // Transform and split chats into private and shared based on visibility
const chats: ChatItem[] = useMemo(() => { const { myChats, sharedChats } = useMemo(() => {
if (!threadsData?.threads) return []; if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
return threadsData.threads.map((thread) => ({
const privateChats: ChatItem[] = [];
const sharedChatsList: ChatItem[] = [];
for (const thread of threadsData.threads) {
const chatItem: ChatItem = {
id: thread.id, id: thread.id,
name: thread.title || `Chat ${thread.id}`, name: thread.title || `Chat ${thread.id}`,
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`, url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
})); visibility: thread.visibility,
}, [threadsData, searchSpaceId]); isOwnThread: thread.is_own_thread,
};
// Transform notes // Split based on visibility, not ownership:
const notes: NoteItem[] = useMemo(() => { // - PRIVATE chats go to "Private Chats" section
if (!notesData?.items) return []; // - SEARCH_SPACE chats go to "Shared Chats" section
const sortedNotes = [...notesData.items].sort((a, b) => { if (thread.visibility === "SEARCH_SPACE") {
const dateA = a.updated_at sharedChatsList.push(chatItem);
? new Date(a.updated_at).getTime() } else {
: new Date(a.created_at).getTime(); privateChats.push(chatItem);
const dateB = b.updated_at }
? new Date(b.updated_at).getTime() }
: new Date(b.created_at).getTime();
return dateB - dateA; return { myChats: privateChats, sharedChats: sharedChatsList };
}); }, [threadsData, searchSpaceId]);
return sortedNotes.slice(0, 4).map((note) => ({
id: note.id,
name: note.title,
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
isReindexing: reindexingDocumentIds.has(note.id),
}));
}, [notesData, reindexingDocumentIds]);
// Navigation items // Navigation items
const navItems: NavItem[] = useMemo( const navItems: NavItem[] = useMemo(
@ -210,36 +172,80 @@ export function LayoutDataProvider({
setIsCreateSearchSpaceDialogOpen(true); setIsCreateSearchSpaceDialogOpen(true);
}, []); }, []);
const handleSeeAllSearchSpaces = useCallback(() => {
setIsAllSearchSpacesSheetOpen(true);
}, []);
const handleUserSettings = useCallback(() => { const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings"); router.push("/dashboard/user/settings");
}, [router]); }, [router]);
const handleSearchSpaceSettings = useCallback( const handleSearchSpaceSettings = useCallback(
(id: number) => { (space: SearchSpace) => {
router.push(`/dashboard/${id}/settings`); router.push(`/dashboard/${space.id}/settings`);
}, },
[router] [router]
); );
const handleDeleteSearchSpace = useCallback( const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
async (id: number) => { // If user is owner, show delete dialog; otherwise show leave dialog
await deleteSearchSpace({ id }); if (space.isOwner) {
setSearchSpaceToDelete(space);
setShowDeleteSearchSpaceDialog(true);
} else {
setSearchSpaceToLeave(space);
setShowLeaveSearchSpaceDialog(true);
}
}, []);
const confirmDeleteSearchSpace = useCallback(async () => {
if (!searchSpaceToDelete) return;
setIsDeletingSearchSpace(true);
try {
await deleteSearchSpace({ id: searchSpaceToDelete.id });
refetchSearchSpaces(); refetchSearchSpaces();
if (Number(searchSpaceId) === id && searchSpaces.length > 1) { if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== id); const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id);
if (remaining.length > 0) { if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`); router.push(`/dashboard/${remaining[0].id}/new-chat`);
} }
} else if (searchSpaces.length === 1) { } else if (searchSpaces.length === 1) {
router.push("/dashboard"); router.push("/dashboard");
} }
}, } catch (error) {
[deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router] console.error("Error deleting search space:", error);
); } finally {
setIsDeletingSearchSpace(false);
setShowDeleteSearchSpaceDialog(false);
setSearchSpaceToDelete(null);
}
}, [
searchSpaceToDelete,
deleteSearchSpace,
refetchSearchSpaces,
searchSpaceId,
searchSpaces,
router,
]);
const confirmLeaveSearchSpace = useCallback(async () => {
if (!searchSpaceToLeave) return;
setIsLeavingSearchSpace(true);
try {
await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id);
refetchSearchSpaces();
if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id);
if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`);
}
} else if (searchSpaces.length === 1) {
router.push("/dashboard");
}
} catch (error) {
console.error("Error leaving search space:", error);
} finally {
setIsLeavingSearchSpace(false);
setShowLeaveSearchSpaceDialog(false);
setSearchSpaceToLeave(null);
}
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]);
const handleNavItemClick = useCallback( const handleNavItemClick = useCallback(
(item: NavItem) => { (item: NavItem) => {
@ -264,34 +270,6 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true); setShowDeleteChatDialog(true);
}, []); }, []);
const handleNoteSelect = useCallback(
(note: NoteItem) => {
if (hasUnsavedEditorChanges) {
setPendingNavigation(note.url);
} else {
router.push(note.url);
}
},
[router, hasUnsavedEditorChanges, setPendingNavigation]
);
const handleNoteDelete = useCallback(
(note: NoteItem) => {
setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) });
setShowDeleteNoteDialog(true);
},
[searchSpaceId]
);
const handleAddNote = useCallback(() => {
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
if (hasUnsavedEditorChanges) {
setPendingNavigation(newNoteUrl);
} else {
router.push(newNoteUrl);
}
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
const handleSettings = useCallback(() => { const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings`); router.push(`/dashboard/${searchSpaceId}/settings`);
}, [router, searchSpaceId]); }, [router, searchSpaceId]);
@ -318,12 +296,12 @@ export function LayoutDataProvider({
setTheme(theme === "dark" ? "light" : "dark"); setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]); }, [theme, setTheme]);
const handleViewAllChats = useCallback(() => { const handleViewAllSharedChats = useCallback(() => {
setIsAllChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen(true);
}, []); }, []);
const handleViewAllNotes = useCallback(() => { const handleViewAllPrivateChats = useCallback(() => {
setIsAllNotesSidebarOpen(true); setIsAllPrivateChatsSidebarOpen(true);
}, []); }, []);
// Delete handlers // Delete handlers
@ -345,24 +323,6 @@ export function LayoutDataProvider({
} }
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
const confirmDeleteNote = useCallback(async () => {
if (!noteToDelete) return;
setIsDeletingNote(true);
try {
await notesApiService.deleteNote({
search_space_id: noteToDelete.search_space_id,
note_id: noteToDelete.id,
});
refetchNotes();
} catch (error) {
console.error("Error deleting note:", error);
} finally {
setIsDeletingNote(false);
setShowDeleteNoteDialog(false);
setNoteToDelete(null);
}
}, [noteToDelete, refetchNotes]);
// Page usage // Page usage
const pageUsage = user const pageUsage = user
? { ? {
@ -380,26 +340,23 @@ export function LayoutDataProvider({
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
activeSearchSpaceId={Number(searchSpaceId)} activeSearchSpaceId={Number(searchSpaceId)}
onSearchSpaceSelect={handleSearchSpaceSelect} onSearchSpaceSelect={handleSearchSpaceSelect}
onSearchSpaceDelete={handleSearchSpaceDeleteClick}
onSearchSpaceSettings={handleSearchSpaceSettings}
onAddSearchSpace={handleAddSearchSpace} onAddSearchSpace={handleAddSearchSpace}
searchSpace={activeSearchSpace} searchSpace={activeSearchSpace}
navItems={navItems} navItems={navItems}
onNavItemClick={handleNavItemClick} onNavItemClick={handleNavItemClick}
chats={chats} chats={myChats}
sharedChats={sharedChats}
activeChatId={currentChatId} activeChatId={currentChatId}
onNewChat={handleNewChat} onNewChat={handleNewChat}
onChatSelect={handleChatSelect} onChatSelect={handleChatSelect}
onChatDelete={handleChatDelete} onChatDelete={handleChatDelete}
onViewAllChats={handleViewAllChats} onViewAllSharedChats={handleViewAllSharedChats}
notes={notes} onViewAllPrivateChats={handleViewAllPrivateChats}
activeNoteId={currentNoteId}
onNoteSelect={handleNoteSelect}
onNoteDelete={handleNoteDelete}
onAddNote={handleAddNote}
onViewAllNotes={handleViewAllNotes}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings} onSettings={handleSettings}
onManageMembers={handleManageMembers} onManageMembers={handleManageMembers}
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
onUserSettings={handleUserSettings} onUserSettings={handleUserSettings}
onLogout={handleLogout} onLogout={handleLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
@ -455,69 +412,33 @@ export function LayoutDataProvider({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* All Chats Sidebar */} {/* Delete Search Space Dialog */}
<AllChatsSidebar <Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
open={isAllChatsSidebarOpen}
onOpenChange={setIsAllChatsSidebarOpen}
searchSpaceId={searchSpaceId}
/>
{/* All Notes Sidebar */}
<AllNotesSidebar
open={isAllNotesSidebarOpen}
onOpenChange={setIsAllNotesSidebarOpen}
searchSpaceId={searchSpaceId}
onAddNote={handleAddNote}
/>
{/* All Search Spaces Sheet */}
<AllSearchSpacesSheet
open={isAllSearchSpacesSheetOpen}
onOpenChange={setIsAllSearchSpacesSheetOpen}
searchSpaces={searchSpaces}
onSearchSpaceSelect={handleSearchSpaceSelect}
onCreateNew={() => {
setIsAllSearchSpacesSheetOpen(false);
setIsCreateSearchSpaceDialogOpen(true);
}}
onSettings={handleSearchSpaceSettings}
onDelete={handleDeleteSearchSpace}
/>
{/* Create Search Space Dialog */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
{/* Delete Note Dialog */}
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" /> <Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_note")}</span> <span>{t("delete_search_space")}</span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "} {t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
{t("action_cannot_undone")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowDeleteNoteDialog(false)} onClick={() => setShowDeleteSearchSpaceDialog(false)}
disabled={isDeletingNote} disabled={isDeletingSearchSpace}
> >
{tCommon("cancel")} {tCommon("cancel")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={confirmDeleteNote} onClick={confirmDeleteSearchSpace}
disabled={isDeletingNote} disabled={isDeletingSearchSpace}
className="gap-2" className="gap-2"
> >
{isDeletingNote ? ( {isDeletingSearchSpace ? (
<> <>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")} {t("deleting")}
@ -532,6 +453,68 @@ export function LayoutDataProvider({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Leave Search Space Dialog */}
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LogOut className="h-5 w-5 text-destructive" />
<span>{t("leave_title")}</span>
</DialogTitle>
<DialogDescription>
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowLeaveSearchSpaceDialog(false)}
disabled={isLeavingSearchSpace}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmLeaveSearchSpace}
disabled={isLeavingSearchSpace}
className="gap-2"
>
{isLeavingSearchSpace ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("leaving")}
</>
) : (
<>
<LogOut className="h-4 w-4" />
{t("leave")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* All Shared Chats Sidebar */}
<AllSharedChatsSidebar
open={isAllSharedChatsSidebarOpen}
onOpenChange={setIsAllSharedChatsSidebarOpen}
searchSpaceId={searchSpaceId}
/>
{/* All Private Chats Sidebar */}
<AllPrivateChatsSidebar
open={isAllPrivateChatsSidebarOpen}
onOpenChange={setIsAllPrivateChatsSidebarOpen}
searchSpaceId={searchSpaceId}
/>
{/* Create Search Space Dialog */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
</> </>
); );
} }

View file

@ -27,14 +27,8 @@ export interface ChatItem {
name: string; name: string;
url: string; url: string;
isActive?: boolean; isActive?: boolean;
} visibility?: "PRIVATE" | "SEARCH_SPACE";
isOwnThread?: boolean;
export interface NoteItem {
id: number;
name: string;
url: string;
isActive?: boolean;
isReindexing?: boolean;
} }
export interface PageUsage { export interface PageUsage {
@ -72,17 +66,8 @@ export interface ChatsSectionProps {
activeChatId?: number | null; activeChatId?: number | null;
onChatSelect: (chat: ChatItem) => void; onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void; onViewAllSharedChats?: () => void;
searchSpaceId?: string; onViewAllPrivateChats?: () => void;
}
export interface NotesSectionProps {
notes: NoteItem[];
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
searchSpaceId?: string; searchSpaceId?: string;
} }
@ -107,22 +92,17 @@ export interface SidebarProps {
searchSpaceId?: string; searchSpaceId?: string;
navItems: NavItem[]; navItems: NavItem[];
chats: ChatItem[]; chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null; activeChatId?: number | null;
onNewChat: () => void; onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void; onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void; onViewAllSharedChats?: () => void;
notes: NoteItem[]; onViewAllPrivateChats?: () => void;
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User; user: User;
theme?: string; theme?: string;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onToggleTheme?: () => void; onToggleTheme?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;

View file

@ -12,6 +12,8 @@ interface IconRailProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null; activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void; onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void; onAddSearchSpace: () => void;
className?: string; className?: string;
} }
@ -20,6 +22,8 @@ export function IconRail({
searchSpaces, searchSpaces,
activeSearchSpaceId, activeSearchSpaceId,
onSearchSpaceSelect, onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace, onAddSearchSpace,
className, className,
}: IconRailProps) { }: IconRailProps) {
@ -32,7 +36,13 @@ export function IconRail({
key={searchSpace.id} key={searchSpace.id}
name={searchSpace.name} name={searchSpace.name}
isActive={searchSpace.id === activeSearchSpaceId} isActive={searchSpace.id === activeSearchSpaceId}
isShared={searchSpace.memberCount > 1}
isOwner={searchSpace.isOwner}
onClick={() => onSearchSpaceSelect(searchSpace.id)} onClick={() => onSearchSpaceSelect(searchSpace.id)}
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(searchSpace) : undefined}
onSettings={
onSearchSpaceSettings ? () => onSearchSpaceSettings(searchSpace) : undefined
}
size="md" size="md"
/> />
))} ))}

View file

@ -1,12 +1,25 @@
"use client"; "use client";
import { Settings, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface SearchSpaceAvatarProps { interface SearchSpaceAvatarProps {
name: string; name: string;
isActive?: boolean; isActive?: boolean;
isShared?: boolean;
isOwner?: boolean;
onClick?: () => void; onClick?: () => void;
onDelete?: () => void;
onSettings?: () => void;
size?: "sm" | "md"; size?: "sm" | "md";
} }
@ -45,21 +58,36 @@ function getInitials(name: string): string {
export function SearchSpaceAvatar({ export function SearchSpaceAvatar({
name, name,
isActive, isActive,
isShared,
isOwner = true,
onClick, onClick,
onDelete,
onSettings,
size = "md", size = "md",
}: SearchSpaceAvatarProps) { }: SearchSpaceAvatarProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const bgColor = stringToColor(name); const bgColor = stringToColor(name);
const initials = getInitials(name); const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
return ( const tooltipContent = (
<Tooltip> <div className="flex flex-col">
<TooltipTrigger asChild> <span>{name}</span>
{isShared && (
<span className="text-xs text-muted-foreground">
{isOwner ? tCommon("owner") : tCommon("shared")}
</span>
)}
</div>
);
const avatarButton = (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex items-center justify-center rounded-lg font-semibold text-white transition-all", "relative flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses, sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background" isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
@ -67,10 +95,66 @@ export function SearchSpaceAvatar({
style={{ backgroundColor: bgColor }} style={{ backgroundColor: bgColor }}
> >
{initials} {initials}
{/* Shared indicator badge */}
{isShared && (
<span
className={cn(
"absolute -top-1 -right-1 flex items-center justify-center rounded-full bg-blue-500 text-white shadow-sm",
size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"
)}
title={tCommon("shared")}
>
<Users className={cn(size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5")} />
</span>
)}
</button> </button>
);
// If delete or settings handlers are provided, wrap with context menu
if (onDelete || onSettings) {
return (
<ContextMenu>
<Tooltip>
<TooltipTrigger asChild>
<ContextMenuTrigger asChild>
<div className="inline-block">{avatarButton}</div>
</ContextMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={8}> <TooltipContent side="right" sideOffset={8}>
{name} {tooltipContent}
</TooltipContent>
</Tooltip>
<ContextMenuContent className="w-48">
{onSettings && (
<ContextMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</ContextMenuItem>
)}
{onSettings && onDelete && <ContextMenuSeparator />}
{onDelete && isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</ContextMenuItem>
)}
{onDelete && !isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("leave")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
// No context menu needed
return (
<Tooltip>
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltipContent}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -1,14 +1,12 @@
export { CreateSearchSpaceDialog } from "./dialogs"; export { CreateSearchSpaceDialog } from "./dialogs";
export { Header } from "./header"; export { Header } from "./header";
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
export { AllSearchSpacesSheet } from "./sheets";
export { LayoutShell } from "./shell"; export { LayoutShell } from "./shell";
export { export {
ChatListItem, ChatListItem,
MobileSidebar, MobileSidebar,
MobileSidebarTrigger, MobileSidebarTrigger,
NavSection, NavSection,
NoteListItem,
PageUsageDisplay, PageUsageDisplay,
Sidebar, Sidebar,
SidebarCollapseButton, SidebarCollapseButton,

View file

@ -1,241 +0,0 @@
"use client";
import {
Calendar,
MoreHorizontal,
Search,
Settings,
Share2,
Trash2,
UserCheck,
Users,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { SearchSpace } from "../../types/layout.types";
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface AllSearchSpacesSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaces: SearchSpace[];
onSearchSpaceSelect: (id: number) => void;
onCreateNew?: () => void;
onSettings?: (id: number) => void;
onDelete?: (id: number) => void;
}
export function AllSearchSpacesSheet({
open,
onOpenChange,
searchSpaces,
onSearchSpaceSelect,
onCreateNew,
onSettings,
onDelete,
}: AllSearchSpacesSheetProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [spaceToDelete, setSpaceToDelete] = useState<SearchSpace | null>(null);
const handleSelect = (id: number) => {
onSearchSpaceSelect(id);
onOpenChange(false);
};
const handleSettings = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
onOpenChange(false);
onSettings?.(space.id);
};
const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
setSpaceToDelete(space);
};
const confirmDelete = () => {
if (spaceToDelete) {
onDelete?.(spaceToDelete.id);
setSpaceToDelete(null);
}
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div className="flex flex-col gap-0.5">
<SheetTitle>{t("all_search_spaces")}</SheetTitle>
<SheetDescription>
{t("search_spaces_count", { count: searchSpaces.length })}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
{searchSpaces.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="font-medium">{t("no_search_spaces")}</p>
<p className="text-sm text-muted-foreground">{t("create_first_search_space")}</p>
</div>
{onCreateNew && (
<Button onClick={onCreateNew} className="mt-2">
{t("create_button")}
</Button>
)}
</div>
) : (
searchSpaces.map((space) => (
<button
key={space.id}
type="button"
onClick={() => handleSelect(space.id)}
className="flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium leading-tight">{space.name}</span>
{space.description && (
<span className="text-sm text-muted-foreground line-clamp-2">
{space.description}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{space.memberCount > 1 && (
<Badge variant="outline" className="shrink-0">
<Share2 className="mr-1 h-3 w-3" />
{tCommon("shared")}
</Badge>
)}
{space.isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleSettings(e, space)}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteClick(e, space)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{space.isOwner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
{t("members_count", { count: space.memberCount })}
</span>
{space.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(space.createdAt)}
</span>
)}
</div>
</button>
))
)}
</div>
{searchSpaces.length > 0 && onCreateNew && (
<div className="border-t p-4">
<Button onClick={onCreateNew} variant="outline" className="w-full">
{t("create_new_search_space")}
</Button>
</div>
)}
</SheetContent>
</Sheet>
<AlertDialog open={!!spaceToDelete} onOpenChange={(open) => !open && setSpaceToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_confirm", { name: spaceToDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1 +0,0 @@
export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet";

View file

@ -5,14 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarState } from "../../hooks"; import { useSidebarState } from "../../hooks";
import type { import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
ChatItem,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
} from "../../types/layout.types";
import { Header } from "../header"; import { Header } from "../header";
import { IconRail } from "../icon-rail"; import { IconRail } from "../icon-rail";
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
@ -21,26 +14,23 @@ interface LayoutShellProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null; activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void; onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void; onAddSearchSpace: () => void;
searchSpace: SearchSpace | null; searchSpace: SearchSpace | null;
navItems: NavItem[]; navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void; onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[]; chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null; activeChatId?: number | null;
onNewChat: () => void; onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void; onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void; onViewAllSharedChats?: () => void;
notes: NoteItem[]; onViewAllPrivateChats?: () => void;
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
@ -58,26 +48,23 @@ export function LayoutShell({
searchSpaces, searchSpaces,
activeSearchSpaceId, activeSearchSpaceId,
onSearchSpaceSelect, onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace, onAddSearchSpace,
searchSpace, searchSpace,
navItems, navItems,
onNavItemClick, onNavItemClick,
chats, chats,
sharedChats,
activeChatId, activeChatId,
onNewChat, onNewChat,
onChatSelect, onChatSelect,
onChatDelete, onChatDelete,
onViewAllChats, onViewAllSharedChats,
notes, onViewAllPrivateChats,
activeNoteId,
onNoteSelect,
onNoteDelete,
onAddNote,
onViewAllNotes,
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
@ -113,26 +100,23 @@ export function LayoutShell({
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId} activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect} onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace} onAddSearchSpace={onAddSearchSpace}
searchSpace={searchSpace} searchSpace={searchSpace}
navItems={navItems} navItems={navItems}
onNavItemClick={onNavItemClick} onNavItemClick={onNavItemClick}
chats={chats} chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId} activeChatId={activeChatId}
onNewChat={onNewChat} onNewChat={onNewChat}
onChatSelect={onChatSelect} onChatSelect={onChatSelect}
onChatDelete={onChatDelete} onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats} onViewAllSharedChats={onViewAllSharedChats}
notes={notes} onViewAllPrivateChats={onViewAllPrivateChats}
activeNoteId={activeNoteId}
onNoteSelect={onNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
@ -155,6 +139,8 @@ export function LayoutShell({
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId} activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect} onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace} onAddSearchSpace={onAddSearchSpace}
/> />
</div> </div>
@ -167,21 +153,16 @@ export function LayoutShell({
navItems={navItems} navItems={navItems}
onNavItemClick={onNavItemClick} onNavItemClick={onNavItemClick}
chats={chats} chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId} activeChatId={activeChatId}
onNewChat={onNewChat} onNewChat={onNewChat}
onChatSelect={onChatSelect} onChatSelect={onChatSelect}
onChatDelete={onChatDelete} onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats} onViewAllSharedChats={onViewAllSharedChats}
notes={notes} onViewAllPrivateChats={onViewAllPrivateChats}
activeNoteId={activeNoteId}
onNoteSelect={onNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}

View file

@ -1,407 +0,0 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { cn } from "@/lib/utils";
interface AllNotesSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onAddNote?: () => void;
onCloseMobileSidebar?: () => void;
}
export function AllNotesSidebar({
open,
onOpenChange,
searchSpaceId,
onAddNote,
onCloseMobileSidebar,
}: AllNotesSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
// Get the current note ID from URL to highlight the open note
const currentNoteId = params.note_id ? Number(params.note_id) : null;
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
// Handle mounting for portal
useEffect(() => {
setMounted(true);
}, []);
// Handle escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
// Lock body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
// Fetch all notes (when no search query)
const {
data: notesData,
error: notesError,
isLoading: isLoadingNotes,
} = useQuery({
queryKey: ["all-notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
page_size: 1000,
}),
enabled: !!searchSpaceId && open && !debouncedSearchQuery,
});
// Search notes (when there's a search query)
const {
data: searchData,
error: searchError,
isLoading: isSearching,
} = useQuery({
queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery],
queryFn: () =>
documentsApiService.searchDocuments({
queryParams: {
search_space_id: Number(searchSpaceId),
document_types: ["NOTE"],
title: debouncedSearchQuery,
page_size: 100,
},
}),
enabled: !!searchSpaceId && open && !!debouncedSearchQuery,
});
// Handle note navigation
const handleNoteClick = useCallback(
(noteId: number, noteSearchSpaceId: number) => {
router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`);
onOpenChange(false);
// Also close the main sidebar on mobile
onCloseMobileSidebar?.();
},
[router, onOpenChange, onCloseMobileSidebar]
);
// Handle note deletion
const handleDeleteNote = useCallback(
async (noteId: number, noteSearchSpaceId: number) => {
setDeletingNoteId(noteId);
try {
await notesApiService.deleteNote({
search_space_id: noteSearchSpaceId,
note_id: noteId,
});
queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] });
} catch (error) {
console.error("Error deleting note:", error);
} finally {
setDeletingNoteId(null);
}
},
[queryClient, searchSpaceId]
);
// Clear search
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
// Determine which data to show
const isSearchMode = !!debouncedSearchQuery;
const isLoading = isSearchMode ? isSearching : isLoadingNotes;
const error = isSearchMode ? searchError : notesError;
// Transform and sort notes data - handle both regular notes and search results
const notes = useMemo(() => {
let notesList: {
id: number;
title: string;
search_space_id: number;
created_at: string;
updated_at?: string | null;
}[];
if (isSearchMode && searchData?.items) {
notesList = searchData.items.map((doc) => ({
id: doc.id,
title: doc.title,
search_space_id: doc.search_space_id,
created_at: doc.created_at,
updated_at: doc.updated_at,
}));
} else {
notesList = notesData?.items ?? [];
}
// Sort notes by updated_at (most recent first), fallback to created_at
return [...notesList].sort((a, b) => {
const dateA = a.updated_at
? new Date(a.updated_at).getTime()
: new Date(a.created_at).getTime();
const dateB = b.updated_at
? new Date(b.updated_at).getTime()
: new Date(b.created_at).getTime();
return dateB - dateA; // Descending order (most recent first)
});
}, [isSearchMode, searchData, notesData]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<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"
/>
{/* Panel */}
<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-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
aria-label={t("all_notes") || "All Notes"}
>
{/* Header */}
<div className="flex-shrink-0 p-4 pb-3 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("all_notes") || "All Notes"}</h2>
<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>
{/* Search Input */}
<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_notes") || "Search notes..."}
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>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_notes") || "Error loading notes"}
</div>
) : notes.length > 0 ? (
<div className="space-y-1">
{notes.map((note) => {
const isDeleting = deletingNoteId === note.id;
const isActive = currentNoteId === note.id;
return (
<div
key={note.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isDeleting && "opacity-50 pointer-events-none"
)}
>
{/* Main clickable area for navigation */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleNoteClick(note.id, note.search_space_id)}
disabled={isDeleting}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{note.title}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className="space-y-1">
<p>
{t("created") || "Created"}:{" "}
{format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")}
</p>
{note.updated_at && (
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
{/* Actions dropdown - separate from main click area */}
<DropdownMenu
open={openDropdownId === note.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? note.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-[80]">
<DropdownMenuItem
onClick={() => handleDeleteNote(note.id, note.search_space_id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No notes 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">
<FileText className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
{t("no_notes") || "No notes yet"}
</p>
{onAddNote && (
<Button
variant="outline"
size="sm"
onClick={() => {
onAddNote();
onOpenChange(false);
}}
>
<Plus className="mr-2 h-4 w-4" />
{t("create_new_note") || "Create a note"}
</Button>
)}
</div>
)}
</div>
{/* Footer with Add Note button */}
{onAddNote && notes.length > 0 && (
<div className="flex-shrink-0 p-3">
<Button
onClick={() => {
onAddNote();
onOpenChange(false);
}}
className="w-full"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
{t("create_new_note") || "Create a new note"}
</Button>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
}

View file

@ -5,6 +5,7 @@ import { format } from "date-fns";
import { import {
ArchiveIcon, ArchiveIcon,
Loader2, Loader2,
Lock,
MessageCircleMore, MessageCircleMore,
MoreHorizontal, MoreHorizontal,
RotateCcwIcon, RotateCcwIcon,
@ -15,7 +16,7 @@ import {
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -38,25 +39,24 @@ import {
} from "@/lib/chat/thread-persistence"; } from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AllChatsSidebarProps { interface AllPrivateChatsSidebarProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
searchSpaceId: string; searchSpaceId: string;
onCloseMobileSidebar?: () => void; onCloseMobileSidebar?: () => void;
} }
export function AllChatsSidebar({ export function AllPrivateChatsSidebar({
open, open,
onOpenChange, onOpenChange,
searchSpaceId, searchSpaceId,
onCloseMobileSidebar, onCloseMobileSidebar,
}: AllChatsSidebarProps) { }: AllPrivateChatsSidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Get the current chat ID from URL to check if user is deleting the currently open chat
const currentChatId = Array.isArray(params.chat_id) const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0]) ? Number(params.chat_id[0])
: params.chat_id : params.chat_id
@ -72,12 +72,10 @@ export function AllChatsSidebar({
const isSearchMode = !!debouncedSearchQuery.trim(); const isSearchMode = !!debouncedSearchQuery.trim();
// Handle mounting for portal
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// Handle escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) { if (e.key === "Escape" && open) {
@ -88,7 +86,6 @@ export function AllChatsSidebar({
return () => document.removeEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
// Lock body scroll when open
useEffect(() => { useEffect(() => {
if (open) { if (open) {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
@ -100,7 +97,6 @@ export function AllChatsSidebar({
}; };
}, [open]); }, [open]);
// Fetch all threads (when not searching)
const { const {
data: threadsData, data: threadsData,
error: threadsError, error: threadsError,
@ -111,7 +107,6 @@ export function AllChatsSidebar({
enabled: !!searchSpaceId && open && !isSearchMode, enabled: !!searchSpaceId && open && !isSearchMode,
}); });
// Search threads (when searching)
const { const {
data: searchData, data: searchData,
error: searchError, error: searchError,
@ -122,18 +117,41 @@ export function AllChatsSidebar({
enabled: !!searchSpaceId && open && isSearchMode, enabled: !!searchSpaceId && open && isSearchMode,
}); });
// Handle thread navigation // Filter to only private chats (PRIVATE visibility or no visibility set)
const { activeChats, archivedChats } = useMemo(() => {
if (isSearchMode) {
const privateSearchResults = (searchData ?? []).filter(
(thread) => thread.visibility !== "SEARCH_SPACE"
);
return {
activeChats: privateSearchResults.filter((t) => !t.archived),
archivedChats: privateSearchResults.filter((t) => t.archived),
};
}
if (!threadsData) return { activeChats: [], archivedChats: [] };
const activePrivate = threadsData.threads.filter(
(thread) => thread.visibility !== "SEARCH_SPACE"
);
const archivedPrivate = threadsData.archived_threads.filter(
(thread) => thread.visibility !== "SEARCH_SPACE"
);
return { activeChats: activePrivate, archivedChats: archivedPrivate };
}, [threadsData, searchData, isSearchMode]);
const threads = showArchived ? archivedChats : activeChats;
const handleThreadClick = useCallback( const handleThreadClick = useCallback(
(threadId: number) => { (threadId: number) => {
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
onOpenChange(false); onOpenChange(false);
// Also close the main sidebar on mobile
onCloseMobileSidebar?.(); onCloseMobileSidebar?.();
}, },
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar] [router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
); );
// Handle thread deletion
const handleDeleteThread = useCallback( const handleDeleteThread = useCallback(
async (threadId: number) => { async (threadId: number) => {
setDeletingThreadId(threadId); setDeletingThreadId(threadId);
@ -144,10 +162,8 @@ export function AllChatsSidebar({
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
// If the deleted chat is currently open, close sidebar first then redirect
if (currentChatId === threadId) { if (currentChatId === threadId) {
onOpenChange(false); onOpenChange(false);
// Wait for sidebar close animation to complete before navigating
setTimeout(() => { setTimeout(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, 250); }, 250);
@ -162,7 +178,6 @@ export function AllChatsSidebar({
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
); );
// Handle thread archive/unarchive
const handleToggleArchive = useCallback( const handleToggleArchive = useCallback(
async (threadId: number, currentlyArchived: boolean) => { async (threadId: number, currentlyArchived: boolean) => {
setArchivingThreadId(threadId); setArchivingThreadId(threadId);
@ -186,25 +201,15 @@ export function AllChatsSidebar({
[queryClient, searchSpaceId, t] [queryClient, searchSpaceId, t]
); );
// Clear search
const handleClearSearch = useCallback(() => { const handleClearSearch = useCallback(() => {
setSearchQuery(""); setSearchQuery("");
}, []); }, []);
// Determine which data source to use
let threads: ThreadListItem[] = [];
if (isSearchMode) {
threads = searchData ?? [];
} else if (threadsData) {
threads = showArchived ? threadsData.archived_threads : threadsData.threads;
}
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
const error = isSearchMode ? searchError : threadsError; const error = isSearchMode ? searchError : threadsError;
// Get counts for tabs const activeCount = activeChats.length;
const activeCount = threadsData?.threads.length ?? 0; const archivedCount = archivedChats.length;
const archivedCount = threadsData?.archived_threads.length ?? 0;
if (!mounted) return null; if (!mounted) return null;
@ -212,32 +217,32 @@ export function AllChatsSidebar({
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <>
{/* Backdrop */}
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="fixed inset-0 z-[70] bg-black/50" className="fixed inset-0 z-70 bg-black/50"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
aria-hidden="true" aria-hidden="true"
/> />
{/* Panel */}
<motion.div <motion.div
initial={{ x: "-100%" }} initial={{ x: "-100%" }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: "-100%" }} exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }} transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed inset-y-0 left-0 z-[70] w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate" className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("all_chats") || "All Chats"} aria-label={t("chats") || "Private Chats"}
> >
{/* Header */} <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex-shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("all_chats") || "All Chats"}</h2> <div className="flex items-center gap-2">
<Lock className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -249,7 +254,6 @@ export function AllChatsSidebar({
</Button> </Button>
</div> </div>
{/* Search Input */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@ -273,9 +277,8 @@ export function AllChatsSidebar({
</div> </div>
</div> </div>
{/* Tab toggle for active/archived (only show when not searching) */}
{!isSearchMode && ( {!isSearchMode && (
<div className="flex-shrink-0 flex border-b mx-4"> <div className="shrink-0 flex border-b mx-4">
<button <button
type="button" type="button"
onClick={() => setShowArchived(false)} onClick={() => setShowArchived(false)}
@ -303,7 +306,6 @@ export function AllChatsSidebar({
</div> </div>
)} )}
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
@ -332,7 +334,6 @@ export function AllChatsSidebar({
isBusy && "opacity-50 pointer-events-none" isBusy && "opacity-50 pointer-events-none"
)} )}
> >
{/* Main clickable area for navigation */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -353,7 +354,6 @@ export function AllChatsSidebar({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Actions dropdown */}
<DropdownMenu <DropdownMenu
open={openDropdownId === thread.id} open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)} onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
@ -377,7 +377,7 @@ export function AllChatsSidebar({
<span className="sr-only">{t("more_options") || "More options"}</span> <span className="sr-only">{t("more_options") || "More options"}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-[80]"> <DropdownMenuContent align="end" className="w-40 z-80">
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)} onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving} disabled={isArchiving}
@ -420,11 +420,11 @@ export function AllChatsSidebar({
</div> </div>
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
<MessageCircleMore className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" /> <Lock className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{showArchived {showArchived
? t("no_archived_chats") || "No archived chats" ? t("no_archived_chats") || "No archived chats"
: t("no_chats") || "No chats yet"} : t("no_chats") || "No private chats"}
</p> </p>
{!showArchived && ( {!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1"> <p className="text-xs text-muted-foreground/70 mt-1">

View file

@ -0,0 +1,443 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
Loader2,
MessageCircleMore,
MoreHorizontal,
RotateCcwIcon,
Search,
Trash2,
Users,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
deleteThread,
fetchThreads,
searchThreads,
type ThreadListItem,
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
interface AllSharedChatsSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onCloseMobileSidebar?: () => void;
}
export function AllSharedChatsSidebar({
open,
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllSharedChatsSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0])
: params.chat_id
? Number(params.chat_id)
: null;
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [mounted, setMounted] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearchQuery.trim();
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]);
const {
data: threadsData,
error: threadsError,
isLoading: isLoadingThreads,
} = useQuery({
queryKey: ["all-threads", searchSpaceId],
queryFn: () => fetchThreads(Number(searchSpaceId)),
enabled: !!searchSpaceId && open && !isSearchMode,
});
const {
data: searchData,
error: searchError,
isLoading: isLoadingSearch,
} = useQuery({
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
enabled: !!searchSpaceId && open && isSearchMode,
});
// Filter to only shared chats (SEARCH_SPACE visibility)
const { activeChats, archivedChats } = useMemo(() => {
if (isSearchMode) {
const sharedSearchResults = (searchData ?? []).filter(
(thread) => thread.visibility === "SEARCH_SPACE"
);
return {
activeChats: sharedSearchResults.filter((t) => !t.archived),
archivedChats: sharedSearchResults.filter((t) => t.archived),
};
}
if (!threadsData) return { activeChats: [], archivedChats: [] };
const activeShared = threadsData.threads.filter(
(thread) => thread.visibility === "SEARCH_SPACE"
);
const archivedShared = threadsData.archived_threads.filter(
(thread) => thread.visibility === "SEARCH_SPACE"
);
return { activeChats: activeShared, archivedChats: archivedShared };
}, [threadsData, searchData, isSearchMode]);
const threads = showArchived ? archivedChats : activeChats;
const handleThreadClick = useCallback(
(threadId: number) => {
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
onOpenChange(false);
onCloseMobileSidebar?.();
},
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
);
const handleDeleteThread = useCallback(
async (threadId: number) => {
setDeletingThreadId(threadId);
try {
await deleteThread(threadId);
toast.success(t("chat_deleted") || "Chat deleted successfully");
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
if (currentChatId === threadId) {
onOpenChange(false);
setTimeout(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, 250);
}
} catch (error) {
console.error("Error deleting thread:", error);
toast.error(t("error_deleting_chat") || "Failed to delete chat");
} finally {
setDeletingThreadId(null);
}
},
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
);
const handleToggleArchive = useCallback(
async (threadId: number, currentlyArchived: boolean) => {
setArchivingThreadId(threadId);
try {
await updateThread(threadId, { archived: !currentlyArchived });
toast.success(
currentlyArchived
? t("chat_unarchived") || "Chat restored"
: t("chat_archived") || "Chat archived"
);
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
} catch (error) {
console.error("Error archiving thread:", error);
toast.error(t("error_archiving_chat") || "Failed to archive chat");
} finally {
setArchivingThreadId(null);
}
},
[queryClient, searchSpaceId, t]
);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
const error = isSearchMode ? searchError : threadsError;
const activeCount = activeChats.length;
const archivedCount = archivedChats.length;
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-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
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>
<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_chats") || "Search chats..."}
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>
{!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>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_chats") || "Error loading chats"}
</div>
) : threads.length > 0 ? (
<div className="space-y-1">
{threads.map((thread) => {
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
const isActive = currentChatId === thread.id;
return (
<div
key={thread.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats 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">
<Users className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_shared_chats") || "No shared chats"}
</p>
{!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1">
Share a chat to collaborate with your team
</p>
)}
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
}

View file

@ -1,18 +1,10 @@
"use client"; "use client";
import { Menu } from "lucide-react"; import { Menu, Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import type { import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
ChatItem, import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
} from "../../types/layout.types";
import { IconRail } from "../icon-rail";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
interface MobileSidebarProps { interface MobileSidebarProps {
@ -21,26 +13,23 @@ interface MobileSidebarProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null; activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void; onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void; onAddSearchSpace: () => void;
searchSpace: SearchSpace | null; searchSpace: SearchSpace | null;
navItems: NavItem[]; navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void; onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[]; chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null; activeChatId?: number | null;
onNewChat: () => void; onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void; onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void; onViewAllSharedChats?: () => void;
notes: NoteItem[]; onViewAllPrivateChats?: () => void;
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
@ -61,26 +50,23 @@ export function MobileSidebar({
searchSpaces, searchSpaces,
activeSearchSpaceId, activeSearchSpaceId,
onSearchSpaceSelect, onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace, onAddSearchSpace,
searchSpace, searchSpace,
navItems, navItems,
onNavItemClick, onNavItemClick,
chats, chats,
sharedChats,
activeChatId, activeChatId,
onNewChat, onNewChat,
onChatSelect, onChatSelect,
onChatDelete, onChatDelete,
onViewAllChats, onViewAllSharedChats,
notes, onViewAllPrivateChats,
activeNoteId,
onNoteSelect,
onNoteDelete,
onAddNote,
onViewAllNotes,
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
@ -99,27 +85,43 @@ export function MobileSidebar({
onOpenChange(false); onOpenChange(false);
}; };
const handleNoteSelect = (note: NoteItem) => {
onNoteSelect(note);
onOpenChange(false);
};
return ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-[320px] p-0 flex"> <SheetContent side="left" className="w-[300px] p-0 flex flex-col">
<SheetTitle className="sr-only">Navigation</SheetTitle> <SheetTitle className="sr-only">Navigation</SheetTitle>
<div className="shrink-0 border-r bg-muted/40"> {/* Horizontal Search Spaces Rail */}
<ScrollArea className="h-full"> <div className="shrink-0 border-b bg-muted/40 px-2 py-2 overflow-hidden">
<IconRail <div className="flex items-center gap-2 px-1 py-1 overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/20">
searchSpaces={searchSpaces} {searchSpaces.map((space) => (
activeSearchSpaceId={activeSearchSpaceId} <div key={space.id} className="shrink-0">
onSearchSpaceSelect={handleSearchSpaceSelect} <SearchSpaceAvatar
onAddSearchSpace={onAddSearchSpace} name={space.name}
isActive={space.id === activeSearchSpaceId}
isShared={space.memberCount > 1}
isOwner={space.isOwner}
onClick={() => handleSearchSpaceSelect(space.id)}
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined}
onSettings={
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
}
size="md"
/> />
</ScrollArea> </div>
))}
<Button
variant="ghost"
size="icon"
onClick={onAddSearchSpace}
className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add search space</span>
</Button>
</div>
</div> </div>
{/* Sidebar Content */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Sidebar <Sidebar
searchSpace={searchSpace} searchSpace={searchSpace}
@ -127,6 +129,7 @@ export function MobileSidebar({
navItems={navItems} navItems={navItems}
onNavItemClick={handleNavItemClick} onNavItemClick={handleNavItemClick}
chats={chats} chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId} activeChatId={activeChatId}
onNewChat={() => { onNewChat={() => {
onNewChat(); onNewChat();
@ -134,17 +137,11 @@ export function MobileSidebar({
}} }}
onChatSelect={handleChatSelect} onChatSelect={handleChatSelect}
onChatDelete={onChatDelete} onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats} onViewAllSharedChats={onViewAllSharedChats}
notes={notes} onViewAllPrivateChats={onViewAllPrivateChats}
activeNoteId={activeNoteId}
onNoteSelect={handleNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}

View file

@ -1,76 +0,0 @@
"use client";
import { FileText, Loader2, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
interface NoteListItemProps {
name: string;
isActive?: boolean;
isReindexing?: boolean;
onClick?: () => void;
onDelete?: () => void;
}
export function NoteListItem({
name,
isActive,
isReindexing,
onClick,
onDelete,
}: NoteListItemProps) {
const t = useTranslations("sidebar");
return (
<div className="group/item relative">
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
"[&>span:last-child]:truncate",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground"
)}
>
{isReindexing ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
) : (
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span>{name}</span>
</button>
{/* Actions dropdown */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 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" />
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
className="text-destructive focus:text-destructive"
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View file

@ -1,22 +1,14 @@
"use client"; "use client";
import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react"; import { FolderOpen, MessageSquare, PenSquare } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
ChatItem,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
} from "../../types/layout.types";
import { ChatListItem } from "./ChatListItem"; import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection"; import { NavSection } from "./NavSection";
import { NoteListItem } from "./NoteListItem";
import { PageUsageDisplay } from "./PageUsageDisplay"; import { PageUsageDisplay } from "./PageUsageDisplay";
import { SidebarCollapseButton } from "./SidebarCollapseButton"; import { SidebarCollapseButton } from "./SidebarCollapseButton";
import { SidebarHeader } from "./SidebarHeader"; import { SidebarHeader } from "./SidebarHeader";
@ -30,21 +22,16 @@ interface SidebarProps {
navItems: NavItem[]; navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void; onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[]; chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null; activeChatId?: number | null;
onNewChat: () => void; onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void; onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void; onViewAllSharedChats?: () => void;
notes: NoteItem[]; onViewAllPrivateChats?: () => void;
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
@ -58,21 +45,16 @@ export function Sidebar({
navItems, navItems,
onNavItemClick, onNavItemClick,
chats, chats,
sharedChats = [],
activeChatId, activeChatId,
onNewChat, onNewChat,
onChatSelect, onChatSelect,
onChatDelete, onChatDelete,
onViewAllChats, onViewAllSharedChats,
notes, onViewAllPrivateChats,
activeNoteId,
onNoteSelect,
onNoteDelete,
onAddNote,
onViewAllNotes,
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
@ -103,7 +85,6 @@ export function Sidebar({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
/> />
<div className=""> <div className="">
<SidebarCollapseButton <SidebarCollapseButton
@ -143,7 +124,7 @@ export function Sidebar({
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{isCollapsed ? ( {isCollapsed ? (
<div className="flex flex-col items-center gap-2 py-2 w-[60px]"> <div className="flex flex-col items-center gap-2 py-2 w-[60px]">
{chats.length > 0 && ( {(chats.length > 0 || sharedChats.length > 0) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -153,52 +134,78 @@ export function Sidebar({
onClick={() => onToggleCollapse?.()} onClick={() => onToggleCollapse?.()}
> >
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
<span className="sr-only">{t("recent_chats")}</span> <span className="sr-only">{t("chats")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
{t("recent_chats")} ({chats.length}) {t("chats")} ({chats.length + sharedChats.length})
</TooltipContent>
</Tooltip>
)}
{notes.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onToggleCollapse?.()}
>
<FileText className="h-4 w-4" />
<span className="sr-only">{t("notes")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t("notes")} ({notes.length})
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-1 py-2 w-[240px]"> <div className="flex flex-col gap-1 py-2 w-[240px]">
{/* Shared Chats Section */}
<SidebarSection <SidebarSection
title={t("recent_chats")} title={t("shared_chats")}
defaultOpen={true} defaultOpen={true}
action={ action={
onViewAllChats && chats.length > 0 ? ( onViewAllSharedChats ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-5 w-5" className="h-5 w-5"
onClick={onViewAllChats} onClick={onViewAllSharedChats}
> >
<FolderOpen className="h-3.5 w-3.5" /> <FolderOpen className="h-3.5 w-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">{t("view_all_chats")}</TooltipContent> <TooltipContent side="top">
{t("view_all_shared_chats") || "View all shared chats"}
</TooltipContent>
</Tooltip>
) : undefined
}
>
{sharedChats.length > 0 ? (
<div className="flex flex-col gap-0.5">
{sharedChats.map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
onClick={() => onChatSelect(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
</div>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_shared_chats")}</p>
)}
</SidebarSection>
{/* Private Chats Section */}
<SidebarSection
title={t("chats")}
defaultOpen={true}
action={
onViewAllPrivateChats ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{t("view_all_private_chats") || "View all private chats"}
</TooltipContent>
</Tooltip> </Tooltip>
) : undefined ) : undefined
} }
@ -216,67 +223,7 @@ export function Sidebar({
))} ))}
</div> </div>
) : ( ) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_recent_chats")}</p> <p className="px-2 py-1 text-xs text-muted-foreground">{t("no_chats")}</p>
)}
</SidebarSection>
<SidebarSection
title={t("notes")}
defaultOpen={true}
action={
onViewAllNotes && notes.length > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onViewAllNotes}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("view_all_notes")}</TooltipContent>
</Tooltip>
) : undefined
}
persistentAction={
onAddNote && notes.length > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onAddNote}>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("add_note")}</TooltipContent>
</Tooltip>
) : undefined
}
>
{notes.length > 0 ? (
<div className="flex flex-col gap-0.5">
{notes.map((note) => (
<NoteListItem
key={note.id}
name={note.name}
isActive={note.id === activeNoteId}
isReindexing={note.isReindexing}
onClick={() => onNoteSelect(note)}
onDelete={() => onNoteDelete?.(note)}
/>
))}
</div>
) : onAddNote ? (
<button
type="button"
onClick={onAddNote}
className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
{t("create_new_note")}
</button>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_notes")}</p>
)} )}
</SidebarSection> </SidebarSection>
</div> </div>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react"; import { ChevronsUpDown, Settings, Users } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -18,7 +18,6 @@ interface SidebarHeaderProps {
isCollapsed?: boolean; isCollapsed?: boolean;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
className?: string; className?: string;
} }
@ -27,7 +26,6 @@ export function SidebarHeader({
isCollapsed, isCollapsed,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
className, className,
}: SidebarHeaderProps) { }: SidebarHeaderProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
@ -59,11 +57,6 @@ export function SidebarHeader({
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
{t("search_space_settings")} {t("search_space_settings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
<LayoutGrid className="mr-2 h-4 w-4" />
{t("see_all_search_spaces")}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View file

@ -1,9 +1,8 @@
export { AllChatsSidebar } from "./AllChatsSidebar"; export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllNotesSidebar } from "./AllNotesSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { ChatListItem } from "./ChatListItem"; export { ChatListItem } from "./ChatListItem";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection"; export { NavSection } from "./NavSection";
export { NoteListItem } from "./NoteListItem";
export { PageUsageDisplay } from "./PageUsageDisplay"; export { PageUsageDisplay } from "./PageUsageDisplay";
export { Sidebar } from "./Sidebar"; export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton"; export { SidebarCollapseButton } from "./SidebarCollapseButton";

View file

@ -10,6 +10,14 @@ interface MarkdownViewerProps {
export function MarkdownViewer({ content, className }: MarkdownViewerProps) { export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const components: StreamdownProps["components"] = { const components: StreamdownProps["components"] = {
// Define custom components for markdown elements // Define custom components for markdown elements
callout: ({ children, ...props }) => (
<div
className="my-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"
{...props}
>
{children}
</div>
),
p: ({ children, ...props }) => ( p: ({ children, ...props }) => (
<p className="my-2" {...props}> <p className="my-2" {...props}>
{children} {children}

View file

@ -5,14 +5,18 @@ import type {
GlobalNewLLMConfig, GlobalNewLLMConfig,
NewLLMConfigPublic, NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
import { ChatShareButton } from "./chat-share-button";
import { ModelConfigSidebar } from "./model-config-sidebar"; import { ModelConfigSidebar } from "./model-config-sidebar";
import { ModelSelector } from "./model-selector"; import { ModelSelector } from "./model-selector";
interface ChatHeaderProps { interface ChatHeaderProps {
searchSpaceId: number; searchSpaceId: number;
thread?: ThreadRecord | null;
onThreadVisibilityChange?: (visibility: ChatVisibility) => void;
} }
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }: ChatHeaderProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState< const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null NewLLMConfigPublic | GlobalNewLLMConfig | null
@ -46,8 +50,9 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
}, []); }, []);
return ( return (
<> <div className="flex items-center gap-2">
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} /> <ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
<ChatShareButton thread={thread ?? null} onVisibilityChange={onThreadVisibilityChange} />
<ModelConfigSidebar <ModelConfigSidebar
open={sidebarOpen} open={sidebarOpen}
onOpenChange={handleSidebarClose} onOpenChange={handleSidebarClose}
@ -56,6 +61,6 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
mode={sidebarMode} mode={sidebarMode}
/> />
</> </div>
); );
} }

View file

@ -0,0 +1,203 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Globe, Loader2, Lock, Share2, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
type ChatVisibility,
type ThreadRecord,
updateThreadVisibility,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
interface ChatShareButtonProps {
thread: ThreadRecord | null;
onVisibilityChange?: (visibility: ChatVisibility) => void;
className?: string;
}
const visibilityOptions: {
value: ChatVisibility;
label: string;
description: string;
icon: typeof Lock;
}[] = [
{
value: "PRIVATE",
label: "Private",
description: "Only you can access this chat",
icon: Lock,
},
{
value: "SEARCH_SPACE",
label: "Search Space",
description: "All members of this search space can access",
icon: Users,
},
];
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const currentVisibility = thread?.visibility ?? "PRIVATE";
const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it
const handleVisibilityChange = useCallback(
async (newVisibility: ChatVisibility) => {
if (!thread || newVisibility === currentVisibility) {
setOpen(false);
return;
}
setIsUpdating(true);
try {
await updateThreadVisibility(thread.id, newVisibility);
// Refetch all thread queries to update sidebar immediately
await queryClient.refetchQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
});
onVisibilityChange?.(newVisibility);
toast.success(
newVisibility === "SEARCH_SPACE" ? "Chat shared with search space" : "Chat is now private"
);
setOpen(false);
} catch (error) {
console.error("Failed to update visibility:", error);
toast.error("Failed to update sharing settings");
} finally {
setIsUpdating(false);
}
},
[thread, currentVisibility, onVisibilityChange, queryClient]
);
// Don't show if no thread (new chat that hasn't been created yet)
if (!thread) {
return null;
}
const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 md:h-9 gap-1.5 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl",
"border border-border/80 bg-background/50 backdrop-blur-sm",
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
"text-xs md:text-sm font-medium text-foreground",
"focus-visible:ring-0 focus-visible:ring-offset-0",
className
)}
>
<CurrentIcon className="size-3.5 md:size-4 text-muted-foreground" />
<span className="hidden md:inline">
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
</span>
<Share2 className="size-3 md:size-3.5 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[280px] md:w-[320px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60"
align="end"
sideOffset={8}
>
<div className="p-3 md:p-4 border-b border-border/30">
<div className="flex items-center gap-2">
<Share2 className="size-4 md:size-5 text-primary" />
<div>
<h4 className="text-sm font-semibold">Share Chat</h4>
<p className="text-xs text-muted-foreground">
Control who can access this conversation
</p>
</div>
</div>
</div>
<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-xl">
<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;
return (
<button
type="button"
key={option.value}
onClick={() => handleVisibilityChange(option.value)}
disabled={isUpdating}
className={cn(
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none focus:ring-2 focus:ring-primary/20",
isSelected && "bg-accent/80 ring-1 ring-primary/20"
)}
>
<div
className={cn(
"mt-0.5 p-1.5 rounded-md shrink-0",
isSelected ? "bg-primary/10" : "bg-muted"
)}
>
<Icon
className={cn(
"size-3.5",
isSelected ? "text-primary" : "text-muted-foreground"
)}
/>
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
{option.label}
</span>
{isSelected && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
Current
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
{option.description}
</p>
</div>
</button>
);
})}
</div>
{/* Info footer */}
<div className="p-3 bg-muted/30 border-t border-border/30 rounded-b-xl">
<div className="flex items-start gap-2">
<Globe className="size-3.5 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{currentVisibility === "PRIVATE"
? "This chat is private. Only you can view and interact with it."
: "This chat is shared. All search space members can view, continue the conversation, and delete it."}
</p>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -25,9 +25,9 @@ export interface DocumentMentionPickerRef {
interface DocumentMentionPickerProps { interface DocumentMentionPickerProps {
searchSpaceId: number; searchSpaceId: number;
onSelectionChange: (documents: Document[]) => void; onSelectionChange: (documents: Pick<Document, "id" | "title" | "document_type">[]) => void;
onDone: () => void; onDone: () => void;
initialSelectedDocuments?: Document[]; initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
externalSearch?: string; externalSearch?: string;
} }
@ -57,7 +57,9 @@ export const DocumentMentionPicker = forwardRef<
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
// State for pagination // State for pagination
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Document[]>([]); const [accumulatedDocuments, setAccumulatedDocuments] = useState<
Pick<Document, "id" | "title" | "document_type">[]
>([]);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
@ -90,6 +92,17 @@ export const DocumentMentionPicker = forwardRef<
}; };
}, [debouncedSearch, searchSpaceId]); }, [debouncedSearch, searchSpaceId]);
const surfsenseDocsQueryParams = useMemo(() => {
const params: { page: number; page_size: number; title?: string } = {
page: 0,
page_size: PAGE_SIZE,
};
if (debouncedSearch.trim()) {
params.title = debouncedSearch;
}
return params;
}, [debouncedSearch]);
// Use query for fetching first page of documents // Use query for fetching first page of documents
const { data: documents, isLoading: isDocumentsLoading } = useQuery({ const { data: documents, isLoading: isDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
@ -106,22 +119,45 @@ export const DocumentMentionPicker = forwardRef<
enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0, enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0,
}); });
// Update accumulated documents when first page loads // Use query for fetching first page of SurfSense docs
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
queryKey: ["surfsense-docs-mention", debouncedSearch],
queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }),
staleTime: 3 * 60 * 1000,
});
// Update accumulated documents when first page loads - combine both sources
useEffect(() => { useEffect(() => {
if (currentPage === 0) { if (currentPage === 0) {
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
// Add SurfSense docs first (they appear at top)
if (surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) {
combinedDocs.push({
id: doc.id,
title: doc.title,
document_type: "SURFSENSE_DOCS",
});
}
}
// Add regular documents
if (debouncedSearch.trim()) { if (debouncedSearch.trim()) {
if (searchedDocuments) { if (searchedDocuments?.items) {
setAccumulatedDocuments(searchedDocuments.items); combinedDocs.push(...searchedDocuments.items);
setHasMore(searchedDocuments.has_more); setHasMore(searchedDocuments.has_more);
} }
} else { } else {
if (documents) { if (documents?.items) {
setAccumulatedDocuments(documents.items); combinedDocs.push(...documents.items);
setHasMore(documents.has_more); setHasMore(documents.has_more);
} }
} }
setAccumulatedDocuments(combinedDocs);
} }
}, [documents, searchedDocuments, debouncedSearch, currentPage]); }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
// Function to load next page // Function to load next page
const loadNextPage = useCallback(async () => { const loadNextPage = useCallback(async () => {
@ -175,22 +211,24 @@ export const DocumentMentionPicker = forwardRef<
const actualDocuments = accumulatedDocuments; const actualDocuments = accumulatedDocuments;
const actualLoading = const actualLoading =
(debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0; ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) ||
isSurfsenseDocsLoading) &&
currentPage === 0;
// Track already selected document IDs // Track already selected documents using unique key (document_type:id) to avoid ID collisions
const selectedIds = useMemo( const selectedKeys = useMemo(
() => new Set(initialSelectedDocuments.map((d) => d.id)), () => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
[initialSelectedDocuments] [initialSelectedDocuments]
); );
// Filter out already selected documents for navigation // Filter out already selected documents for navigation
const selectableDocuments = useMemo( const selectableDocuments = useMemo(
() => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), () => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
[actualDocuments, selectedIds] [actualDocuments, selectedKeys]
); );
const handleSelectDocument = useCallback( const handleSelectDocument = useCallback(
(doc: Document) => { (doc: Pick<Document, "id" | "title" | "document_type">) => {
onSelectionChange([...initialSelectedDocuments, doc]); onSelectionChange([...initialSelectedDocuments, doc]);
onDone(); onDone();
}, },
@ -287,13 +325,16 @@ export const DocumentMentionPicker = forwardRef<
) : ( ) : (
<div className="py-1"> <div className="py-1">
{actualDocuments.map((doc) => { {actualDocuments.map((doc) => {
const isAlreadySelected = selectedIds.has(doc.id); const docKey = `${doc.document_type}:${doc.id}`;
const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id); const isAlreadySelected = selectedKeys.has(docKey);
const selectableIndex = selectableDocuments.findIndex(
(d) => d.document_type === doc.document_type && d.id === doc.id
);
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
return ( return (
<button <button
key={doc.id} key={docKey}
ref={(el) => { ref={(el) => {
if (el && selectableIndex >= 0) { if (el && selectableIndex >= 0) {
itemRefs.current.set(selectableIndex, el); itemRefs.current.set(selectableIndex, el);

View file

@ -0,0 +1,225 @@
"use client";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View file

@ -1,5 +1,6 @@
import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react"; import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react";
import { import {
BookOpen,
File, File,
FileText, FileText,
Globe, Globe,
@ -86,6 +87,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <FileText {...iconProps} />; return <FileText {...iconProps} />;
case "EXTENSION": case "EXTENSION":
return <Webhook {...iconProps} />; return <Webhook {...iconProps} />;
case "SURFSENSE_DOCS":
return <BookOpen {...iconProps} />;
case "DEEP": case "DEEP":
return <Sparkles {...iconProps} />; return <Sparkles {...iconProps} />;
case "DEEPER": case "DEEPER":

View file

@ -22,6 +22,7 @@ export const documentTypeEnum = z.enum([
"LINEAR_CONNECTOR", "LINEAR_CONNECTOR",
"NOTE", "NOTE",
"CIRCLEBACK", "CIRCLEBACK",
"SURFSENSE_DOCS",
]); ]);
export const document = z.object({ export const document = z.object({
@ -183,6 +184,23 @@ export const getSurfsenseDocsByChunkRequest = z.object({
export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks; export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
/**
* List Surfsense docs
*/
export const getSurfsenseDocsRequest = z.object({
queryParams: paginationQueryParams.extend({
title: z.string().optional(),
}),
});
export const getSurfsenseDocsResponse = z.object({
items: z.array(surfsenseDocsDocument),
total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
});
/** /**
* Update document * Update document
*/ */
@ -227,3 +245,5 @@ export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>; export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>; export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>;
export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>; export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>;
export type GetSurfsenseDocsRequest = z.infer<typeof getSurfsenseDocsRequest>;
export type GetSurfsenseDocsResponse = z.infer<typeof getSurfsenseDocsResponse>;

View file

@ -64,6 +64,13 @@ export const deleteSearchSpaceResponse = z.object({
message: z.literal("Search space deleted successfully"), message: z.literal("Search space deleted successfully"),
}); });
/**
* Leave search space (for non-owners)
*/
export const leaveSearchSpaceResponse = z.object({
message: z.literal("Successfully left the search space"),
});
// Inferred types // Inferred types
export type SearchSpace = z.infer<typeof searchSpace>; export type SearchSpace = z.infer<typeof searchSpace>;
export type GetSearchSpacesRequest = z.infer<typeof getSearchSpacesRequest>; export type GetSearchSpacesRequest = z.infer<typeof getSearchSpacesRequest>;

View file

@ -11,7 +11,7 @@ enum ResponseType {
} }
export type RequestOptions = { export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "DELETE"; method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>; headers?: Record<string, string>;
contentType?: "application/json" | "application/x-www-form-urlencoded"; contentType?: "application/json" | "application/x-www-form-urlencoded";
signal?: AbortSignal; signal?: AbortSignal;
@ -273,6 +273,21 @@ class BaseApiService {
}); });
} }
async patch<T>(
url: string,
responseSchema?: ZodType<T>,
options?: Omit<RequestOptions, "method" | "responseType">
) {
return this.request(url, responseSchema, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
...options,
responseType: ResponseType.JSON,
});
}
async getBlob(url: string, options?: Omit<RequestOptions, "method" | "responseType">) { async getBlob(url: string, options?: Omit<RequestOptions, "method" | "responseType">) {
return this.request(url, undefined, { return this.request(url, undefined, {
...options, ...options,

View file

@ -9,6 +9,7 @@ import {
type GetDocumentRequest, type GetDocumentRequest,
type GetDocumentsRequest, type GetDocumentsRequest,
type GetDocumentTypeCountsRequest, type GetDocumentTypeCountsRequest,
type GetSurfsenseDocsRequest,
getDocumentByChunkRequest, getDocumentByChunkRequest,
getDocumentByChunkResponse, getDocumentByChunkResponse,
getDocumentRequest, getDocumentRequest,
@ -18,6 +19,8 @@ import {
getDocumentTypeCountsRequest, getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse, getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse, getSurfsenseDocsByChunkResponse,
getSurfsenseDocsRequest,
getSurfsenseDocsResponse,
type SearchDocumentsRequest, type SearchDocumentsRequest,
searchDocumentsRequest, searchDocumentsRequest,
searchDocumentsResponse, searchDocumentsResponse,
@ -221,6 +224,35 @@ class DocumentsApiService {
); );
}; };
/**
* List all Surfsense documentation documents
*/
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest) => {
const parsedRequest = getSurfsenseDocsRequest.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}`);
}
// Transform query params to be string values
const transformedQueryParams = parsedRequest.data.queryParams
? Object.fromEntries(
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
)
: undefined;
const queryParams = transformedQueryParams
? new URLSearchParams(transformedQueryParams).toString()
: "";
const url = `/api/v1/surfsense-docs?${queryParams}`;
return baseApiService.get(url, getSurfsenseDocsResponse);
};
/** /**
* Update a document * Update a document
*/ */

View file

@ -11,6 +11,7 @@ import {
getSearchSpaceResponse, getSearchSpaceResponse,
getSearchSpacesRequest, getSearchSpacesRequest,
getSearchSpacesResponse, getSearchSpacesResponse,
leaveSearchSpaceResponse,
type UpdateSearchSpaceRequest, type UpdateSearchSpaceRequest,
updateSearchSpaceRequest, updateSearchSpaceRequest,
updateSearchSpaceResponse, updateSearchSpaceResponse,
@ -115,6 +116,17 @@ class SearchSpacesApiService {
return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse); return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse);
}; };
/**
* Leave a search space (remove own membership)
* This is used by non-owners to leave a shared search space
*/
leaveSearchSpace = async (searchSpaceId: number) => {
return baseApiService.delete(
`/api/v1/searchspaces/${searchSpaceId}/members/me`,
leaveSearchSpaceResponse
);
};
} }
export const searchSpacesApiService = new SearchSpacesApiService(); export const searchSpacesApiService = new SearchSpacesApiService();

View file

@ -9,10 +9,17 @@ import { baseApiService } from "@/lib/apis/base-api.service";
// Types matching backend schemas // Types matching backend schemas
// ============================================================================= // =============================================================================
/**
* Chat visibility levels - matches backend ChatVisibility enum
*/
export type ChatVisibility = "PRIVATE" | "SEARCH_SPACE";
export interface ThreadRecord { export interface ThreadRecord {
id: number; id: number;
title: string; title: string;
archived: boolean; archived: boolean;
visibility: ChatVisibility;
created_by_id: string | null;
search_space_id: number; search_space_id: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -35,6 +42,9 @@ export interface ThreadListItem {
id: number; id: number;
title: string; title: string;
archived: boolean; archived: boolean;
visibility: ChatVisibility;
created_by_id: string | null;
is_own_thread: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@ -127,6 +137,25 @@ export async function deleteThread(threadId: number): Promise<void> {
await baseApiService.delete(`/api/v1/threads/${threadId}`); await baseApiService.delete(`/api/v1/threads/${threadId}`);
} }
/**
* Update thread visibility (share/unshare)
*/
export async function updateThreadVisibility(
threadId: number,
visibility: ChatVisibility
): Promise<ThreadRecord> {
return baseApiService.patch<ThreadRecord>(`/api/v1/threads/${threadId}/visibility`, undefined, {
body: { visibility },
});
}
/**
* Get full thread details including visibility
*/
export async function getThreadFull(threadId: number): Promise<ThreadRecord> {
return baseApiService.get<ThreadRecord>(`/api/v1/threads/${threadId}/full`);
}
// ============================================================================= // =============================================================================
// Thread List Manager (for thread list sidebar) // Thread List Manager (for thread list sidebar)
// ============================================================================= // =============================================================================

View file

@ -97,6 +97,10 @@
"create_new_search_space": "Create new search space", "create_new_search_space": "Create new search space",
"delete_title": "Delete Search Space", "delete_title": "Delete Search Space",
"delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.", "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.",
"leave": "Leave",
"leave_title": "Leave Search Space",
"leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.",
"leaving": "Leaving...",
"welcome_title": "Welcome to SurfSense", "welcome_title": "Welcome to SurfSense",
"welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.", "welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.",
"create_first_button": "Create your first search space" "create_first_button": "Create your first search space"
@ -105,7 +109,6 @@
"title": "User Settings", "title": "User Settings",
"description": "Manage your account settings and API access", "description": "Manage your account settings and API access",
"back_to_app": "Back to app", "back_to_app": "Back to app",
"footer": "User Settings",
"api_key_nav_label": "API Key", "api_key_nav_label": "API Key",
"api_key_nav_description": "Manage your API access token", "api_key_nav_description": "Manage your API access token",
"api_key_title": "API Key", "api_key_title": "API Key",
@ -160,6 +163,10 @@
"go_home": "Go Home", "go_home": "Go Home",
"delete_search_space": "Delete Search Space", "delete_search_space": "Delete Search Space",
"delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.", "delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.",
"leave": "Leave",
"leave_title": "Leave Search Space",
"leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.",
"leaving": "Leaving...",
"no_spaces_found": "No search spaces found", "no_spaces_found": "No search spaces found",
"create_first_space": "Create your first search space to get started", "create_first_space": "Create your first search space to get started",
"created": "Created" "created": "Created"
@ -312,6 +319,8 @@
"rows_per_page": "Rows per page", "rows_per_page": "Rows per page",
"refresh": "Refresh", "refresh": "Refresh",
"refresh_success": "Documents refreshed", "refresh_success": "Documents refreshed",
"upload_documents": "Upload Documents",
"create_shared_note": "Create Shared Note",
"processing_documents": "Processing documents...", "processing_documents": "Processing documents...",
"active_tasks_count": "{count} active task(s)" "active_tasks_count": "{count} active task(s)"
}, },
@ -628,37 +637,23 @@
"manage": "Manage" "manage": "Manage"
}, },
"sidebar": { "sidebar": {
"recent_chats": "Recent Chats", "chats": "Private Chats",
"shared_chats": "Shared Chats",
"search_chats": "Search chats...", "search_chats": "Search chats...",
"no_chats_found": "No chats found", "no_chats_found": "No chats found",
"no_recent_chats": "No recent chats", "no_shared_chats": "No shared chats",
"view_all_chats": "View all chats", "view_all_shared_chats": "View all shared chats",
"all_chats": "All Chats", "view_all_private_chats": "View all private chats",
"all_chats_description": "Browse and manage all your chats",
"no_chats": "No chats yet", "no_chats": "No chats yet",
"start_new_chat_hint": "Start a new chat", "start_new_chat_hint": "Start a new chat",
"error_loading_chats": "Error loading chats", "error_loading_chats": "Error loading chats",
"chat_deleted": "Chat deleted successfully", "chat_deleted": "Chat deleted successfully",
"error_deleting_chat": "Failed to delete chat", "error_deleting_chat": "Failed to delete chat",
"search_space": "Search Space",
"notes": "Notes",
"all_notes": "All Notes",
"all_notes_description": "Browse and manage all your notes",
"search_notes": "Search notes...",
"no_results_found": "No notes found",
"try_different_search": "Try a different search term",
"no_notes": "No notes yet",
"create_new_note": "Create a new note",
"error_loading_notes": "Error loading notes",
"loading": "Loading...",
"deleting": "Deleting...",
"delete": "Delete", "delete": "Delete",
"created": "Created", "try_different_search": "Try a different search term",
"updated": "Updated", "updated": "Updated",
"more_options": "More options", "more_options": "More options",
"clear_search": "Clear search", "clear_search": "Clear search",
"view_all_notes": "View all notes",
"add_note": "Add note",
"archive": "Archive", "archive": "Archive",
"unarchive": "Restore", "unarchive": "Restore",
"chat_archived": "Chat archived", "chat_archived": "Chat archived",
@ -684,6 +679,16 @@
"server_error": "Server error", "server_error": "Server error",
"network_error": "Network error" "network_error": "Network error"
}, },
"searchSpaceSettings": {
"title": "Search Space Settings",
"back_to_app": "Back to app",
"nav_agent_configs": "Agent Configs",
"nav_agent_configs_desc": "LLM models with prompts & citations",
"nav_role_assignments": "Role Assignments",
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_system_instructions": "System Instructions",
"nav_system_instructions_desc": "SearchSpace-wide AI instructions"
},
"homepage": { "homepage": {
"hero_title_part1": "The AI Workspace", "hero_title_part1": "The AI Workspace",
"hero_title_part2": "Built for Teams", "hero_title_part2": "Built for Teams",

View file

@ -105,7 +105,6 @@
"title": "用户设置", "title": "用户设置",
"description": "管理您的账户设置和API访问", "description": "管理您的账户设置和API访问",
"back_to_app": "返回应用", "back_to_app": "返回应用",
"footer": "用户设置",
"api_key_nav_label": "API密钥", "api_key_nav_label": "API密钥",
"api_key_nav_description": "管理您的API访问令牌", "api_key_nav_description": "管理您的API访问令牌",
"api_key_title": "API密钥", "api_key_title": "API密钥",
@ -160,6 +159,10 @@
"go_home": "返回首页", "go_home": "返回首页",
"delete_search_space": "删除搜索空间", "delete_search_space": "删除搜索空间",
"delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。", "delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。",
"leave": "退出",
"leave_title": "退出搜索空间",
"leave_confirm": "您确定要退出\"{name}\"吗?您将无法访问此搜索空间中的所有文档和对话。",
"leaving": "退出中...",
"no_spaces_found": "未找到搜索空间", "no_spaces_found": "未找到搜索空间",
"create_first_space": "创建您的第一个搜索空间以开始使用", "create_first_space": "创建您的第一个搜索空间以开始使用",
"created": "创建于" "created": "创建于"
@ -312,6 +315,8 @@
"rows_per_page": "每页行数", "rows_per_page": "每页行数",
"refresh": "刷新", "refresh": "刷新",
"refresh_success": "文档已刷新", "refresh_success": "文档已刷新",
"upload_documents": "上传文档",
"create_shared_note": "创建共享笔记",
"processing_documents": "正在处理文档...", "processing_documents": "正在处理文档...",
"active_tasks_count": "{count} 个正在进行的工作项" "active_tasks_count": "{count} 个正在进行的工作项"
}, },
@ -628,37 +633,29 @@
"manage": "管理" "manage": "管理"
}, },
"sidebar": { "sidebar": {
"recent_chats": "最近对话", "chats": "私人对话",
"shared_chats": "共享对话",
"search_chats": "搜索对话...", "search_chats": "搜索对话...",
"no_chats_found": "未找到对话", "no_chats_found": "未找到对话",
"no_recent_chats": "暂无最近对话", "no_shared_chats": "暂无共享对话",
"view_all_chats": "查看所有对话", "view_all_shared_chats": "查看所有共享对话",
"all_chats": "所有对话", "view_all_private_chats": "查看所有私人对话",
"all_chats_description": "浏览和管理您的所有对话",
"no_chats": "暂无对话", "no_chats": "暂无对话",
"start_new_chat_hint": "开始新对话", "start_new_chat_hint": "开始新对话",
"error_loading_chats": "加载对话时出错", "error_loading_chats": "加载对话时出错",
"chat_deleted": "对话删除成功", "chat_deleted": "对话删除成功",
"error_deleting_chat": "删除对话失败", "error_deleting_chat": "删除对话失败",
"search_space": "搜索空间",
"notes": "笔记",
"all_notes": "所有笔记",
"all_notes_description": "浏览和管理您的所有笔记",
"search_notes": "搜索笔记...",
"no_results_found": "未找到笔记",
"try_different_search": "尝试其他搜索词",
"no_notes": "暂无笔记",
"create_new_note": "创建新笔记",
"error_loading_notes": "加载笔记时出错",
"loading": "加载中...",
"deleting": "删除中...",
"delete": "删除", "delete": "删除",
"created": "创建时间", "try_different_search": "尝试其他搜索词",
"updated": "更新时间", "updated": "更新时间",
"more_options": "更多选项", "more_options": "更多选项",
"clear_search": "清除搜索", "clear_search": "清除搜索",
"view_all_notes": "查看所有笔记", "archive": "归档",
"add_note": "添加笔记", "unarchive": "恢复",
"chat_archived": "对话已归档",
"chat_unarchived": "对话已恢复",
"no_archived_chats": "暂无已归档对话",
"error_archiving_chat": "归档对话失败",
"new_chat": "新对话", "new_chat": "新对话",
"select_search_space": "选择搜索空间", "select_search_space": "选择搜索空间",
"manage_members": "管理成员", "manage_members": "管理成员",
@ -678,6 +675,16 @@
"server_error": "服务器错误", "server_error": "服务器错误",
"network_error": "网络错误" "network_error": "网络错误"
}, },
"searchSpaceSettings": {
"title": "搜索空间设置",
"back_to_app": "返回应用",
"nav_agent_configs": "代理配置",
"nav_agent_configs_desc": "LLM 模型配置提示词和引用",
"nav_role_assignments": "角色分配",
"nav_role_assignments_desc": "为代理角色分配配置",
"nav_system_instructions": "系统指令",
"nav_system_instructions_desc": "搜索空间级别的 AI 指令"
},
"homepage": { "homepage": {
"hero_title_part1": "AI 工作空间", "hero_title_part1": "AI 工作空间",
"hero_title_part2": "为团队而生", "hero_title_part2": "为团队而生",

View file

@ -37,6 +37,7 @@
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",

View file

@ -56,6 +56,9 @@ importers:
'@radix-ui/react-collapsible': '@radix-ui/react-collapsible':
specifier: ^1.1.11 specifier: ^1.1.11
version: 1.1.12(@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) version: 1.1.12(@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)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@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)
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.15 specifier: ^1.1.15
version: 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) version: 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)
@ -1628,6 +1631,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-context@1.0.0': '@radix-ui/react-context@1.0.0':
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies: peerDependencies:
@ -7422,6 +7438,20 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.7 '@types/react': 19.2.7
'@radix-ui/react-context-menu@2.2.16(@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/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-menu': 2.1.16(@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)
'@radix-ui/react-primitive': 2.1.3(@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)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-context@1.0.0(react@19.2.3)': '@radix-ui/react-context@1.0.0(react@19.2.3)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4