mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: added shared chats
This commit is contained in:
parent
764dd05582
commit
f22d649239
22 changed files with 1881 additions and 506 deletions
|
|
@ -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")
|
||||
|
|
@ -326,6 +326,20 @@ class NewChatMessageRole(str, Enum):
|
|||
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):
|
||||
"""
|
||||
Thread model for the new chat feature using assistant-ui.
|
||||
|
|
@ -345,13 +359,31 @@ class NewChatThread(BaseModel, TimestampMixin):
|
|||
index=True,
|
||||
)
|
||||
|
||||
# Visibility/sharing control
|
||||
visibility = Column(
|
||||
SQLAlchemyEnum(ChatVisibility),
|
||||
nullable=False,
|
||||
default=ChatVisibility.PRIVATE,
|
||||
server_default="PRIVATE",
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
search_space_id = Column(
|
||||
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
|
||||
search_space = relationship("SearchSpace", back_populates="new_chat_threads")
|
||||
created_by = relationship("User", back_populates="new_chat_threads")
|
||||
messages = relationship(
|
||||
"NewChatMessage",
|
||||
back_populates="thread",
|
||||
|
|
@ -826,6 +858,13 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
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
|
||||
pages_limit = Column(
|
||||
Integer,
|
||||
|
|
@ -852,6 +891,13 @@ else:
|
|||
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
|
||||
pages_limit = Column(
|
||||
Integer,
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ from datetime import UTC, datetime
|
|||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import (
|
||||
ChatVisibility,
|
||||
NewChatMessage,
|
||||
NewChatMessageRole,
|
||||
NewChatThread,
|
||||
|
|
@ -40,6 +42,7 @@ from app.schemas.new_chat import (
|
|||
NewChatThreadCreate,
|
||||
NewChatThreadRead,
|
||||
NewChatThreadUpdate,
|
||||
NewChatThreadVisibilityUpdate,
|
||||
NewChatThreadWithMessages,
|
||||
ThreadHistoryLoadResponse,
|
||||
ThreadListItem,
|
||||
|
|
@ -52,6 +55,61 @@ from app.utils.rbac import check_permission
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_thread_access(
|
||||
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 (and user has permission to read chats)
|
||||
- Thread is a legacy thread (created_by_id is NULL) - visible to all
|
||||
|
||||
Args:
|
||||
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)
|
||||
Legacy threads (NULL creator) are treated as accessible by all
|
||||
|
||||
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
|
||||
|
||||
# Legacy threads are accessible to all users in the search space
|
||||
if is_legacy:
|
||||
return True
|
||||
|
||||
# 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 or shared threads
|
||||
if is_owner:
|
||||
return True
|
||||
|
||||
if thread.visibility == ChatVisibility.SEARCH_SPACE:
|
||||
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
|
||||
# =============================================================================
|
||||
|
|
@ -65,9 +123,14 @@ async def list_threads(
|
|||
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.
|
||||
|
||||
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)
|
||||
|
||||
Args:
|
||||
search_space_id: The search space to list threads for
|
||||
limit: Optional limit on number of threads to return (applies to active threads only)
|
||||
|
|
@ -83,10 +146,20 @@ async def list_threads(
|
|||
"You don't have permission to read chats in this search space",
|
||||
)
|
||||
|
||||
# Get all threads in this search space
|
||||
# Get threads that are either:
|
||||
# 1. Created by the current user (any visibility)
|
||||
# 2. Shared with the search space (visibility = SEARCH_SPACE)
|
||||
# 3. Legacy threads with no creator (created_by_id is NULL) - visible to all
|
||||
query = (
|
||||
select(NewChatThread)
|
||||
.filter(NewChatThread.search_space_id == search_space_id)
|
||||
.filter(
|
||||
NewChatThread.search_space_id == search_space_id,
|
||||
or_(
|
||||
NewChatThread.created_by_id == user.id,
|
||||
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
|
||||
NewChatThread.created_by_id.is_(None), # Legacy threads
|
||||
),
|
||||
)
|
||||
.order_by(NewChatThread.updated_at.desc())
|
||||
)
|
||||
|
||||
|
|
@ -98,10 +171,17 @@ async def list_threads(
|
|||
archived_threads = []
|
||||
|
||||
for thread in all_threads:
|
||||
# Legacy threads (no creator) are treated as own threads for display purposes
|
||||
is_own_thread = (
|
||||
thread.created_by_id == user.id or thread.created_by_id is None
|
||||
)
|
||||
item = ThreadListItem(
|
||||
id=thread.id,
|
||||
title=thread.title,
|
||||
archived=thread.archived,
|
||||
visibility=thread.visibility,
|
||||
created_by_id=thread.created_by_id,
|
||||
is_own_thread=is_own_thread,
|
||||
created_at=thread.created_at,
|
||||
updated_at=thread.updated_at,
|
||||
)
|
||||
|
|
@ -137,7 +217,12 @@ async def search_threads(
|
|||
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)
|
||||
|
||||
Args:
|
||||
search_space_id: The search space to search in
|
||||
|
|
@ -154,12 +239,17 @@ async def search_threads(
|
|||
"You don't have permission to read chats in this search space",
|
||||
)
|
||||
|
||||
# Search threads by title (case-insensitive)
|
||||
# Search accessible threads by title (case-insensitive)
|
||||
query = (
|
||||
select(NewChatThread)
|
||||
.filter(
|
||||
NewChatThread.search_space_id == search_space_id,
|
||||
NewChatThread.title.ilike(f"%{title}%"),
|
||||
or_(
|
||||
NewChatThread.created_by_id == user.id,
|
||||
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
|
||||
NewChatThread.created_by_id.is_(None), # Legacy threads
|
||||
),
|
||||
)
|
||||
.order_by(NewChatThread.updated_at.desc())
|
||||
)
|
||||
|
|
@ -172,6 +262,12 @@ async def search_threads(
|
|||
id=thread.id,
|
||||
title=thread.title,
|
||||
archived=thread.archived,
|
||||
visibility=thread.visibility,
|
||||
created_by_id=thread.created_by_id,
|
||||
# Legacy threads (no creator) are treated as own threads
|
||||
is_own_thread=(
|
||||
thread.created_by_id == user.id or thread.created_by_id is None
|
||||
),
|
||||
created_at=thread.created_at,
|
||||
updated_at=thread.updated_at,
|
||||
)
|
||||
|
|
@ -200,6 +296,9 @@ async def create_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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -215,7 +314,9 @@ async def create_thread(
|
|||
db_thread = NewChatThread(
|
||||
title=thread.title,
|
||||
archived=thread.archived,
|
||||
visibility=thread.visibility,
|
||||
search_space_id=thread.search_space_id,
|
||||
created_by_id=user.id,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(db_thread)
|
||||
|
|
@ -254,6 +355,10 @@ async def get_thread_messages(
|
|||
Get a thread with all its messages.
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -268,7 +373,7 @@ async def get_thread_messages(
|
|||
if not thread:
|
||||
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(
|
||||
session,
|
||||
user,
|
||||
|
|
@ -277,6 +382,9 @@ async def get_thread_messages(
|
|||
"You don't have permission to read chats in this search space",
|
||||
)
|
||||
|
||||
# Check thread-level access based on visibility
|
||||
await check_thread_access(thread, user)
|
||||
|
||||
# Return messages in the format expected by assistant-ui
|
||||
messages = [
|
||||
NewChatMessageRead(
|
||||
|
|
@ -313,6 +421,10 @@ async def get_thread_full(
|
|||
"""
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -334,6 +446,9 @@ async def get_thread_full(
|
|||
"You don't have permission to read chats in this search space",
|
||||
)
|
||||
|
||||
# Check thread-level access based on visibility
|
||||
await check_thread_access(thread, user)
|
||||
|
||||
return thread
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -360,6 +475,9 @@ async def update_thread(
|
|||
Update a thread (title, archived status).
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -379,6 +497,11 @@ async def update_thread(
|
|||
"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(db_thread, user, require_ownership=True)
|
||||
|
||||
# Update fields
|
||||
update_data = thread_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
|
|
@ -420,6 +543,9 @@ async def delete_thread(
|
|||
"""
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -439,6 +565,11 @@ async def delete_thread(
|
|||
"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(db_thread, user, require_ownership=True)
|
||||
|
||||
await session.delete(db_thread)
|
||||
await session.commit()
|
||||
return {"message": "Thread deleted successfully"}
|
||||
|
|
@ -463,6 +594,71 @@ async def delete_thread(
|
|||
) 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(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
|
||||
# =============================================================================
|
||||
|
|
@ -479,6 +675,10 @@ async def append_message(
|
|||
Append a message to a thread.
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -513,6 +713,9 @@ async def append_message(
|
|||
"You don't have permission to update chats in this search space",
|
||||
)
|
||||
|
||||
# Check thread-level access based on visibility
|
||||
await check_thread_access(thread, user)
|
||||
|
||||
# Convert string role to enum
|
||||
role_str = (
|
||||
message.role.lower() if isinstance(message.role, str) else message.role
|
||||
|
|
@ -597,6 +800,10 @@ async def list_messages(
|
|||
"""
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -617,6 +824,9 @@ async def list_messages(
|
|||
"You don't have permission to read chats in this search space",
|
||||
)
|
||||
|
||||
# Check thread-level access based on visibility
|
||||
await check_thread_access(thread, user)
|
||||
|
||||
# Get messages
|
||||
query = (
|
||||
select(NewChatMessage)
|
||||
|
|
@ -659,6 +869,10 @@ async def handle_new_chat(
|
|||
This endpoint handles the new chat functionality with streaming responses
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -679,6 +893,9 @@ async def handle_new_chat(
|
|||
"You don't have permission to chat in this search space",
|
||||
)
|
||||
|
||||
# Check thread-level access based on visibility
|
||||
await check_thread_access(thread, user)
|
||||
|
||||
# Get search space to check LLM config preferences
|
||||
search_space_result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern:
|
|||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.db import NewChatMessageRole
|
||||
from app.db import ChatVisibility, NewChatMessageRole
|
||||
|
||||
from .base import IDModel, TimestampModel
|
||||
|
||||
|
|
@ -66,6 +67,8 @@ class NewChatThreadCreate(NewChatThreadBase):
|
|||
"""Schema for creating a new thread."""
|
||||
|
||||
search_space_id: int
|
||||
# Visibility defaults to PRIVATE, but can be set on creation
|
||||
visibility: ChatVisibility = ChatVisibility.PRIVATE
|
||||
|
||||
|
||||
class NewChatThreadUpdate(BaseModel):
|
||||
|
|
@ -75,12 +78,20 @@ class NewChatThreadUpdate(BaseModel):
|
|||
archived: bool | None = None
|
||||
|
||||
|
||||
class NewChatThreadVisibilityUpdate(BaseModel):
|
||||
"""Schema for updating thread visibility/sharing settings."""
|
||||
|
||||
visibility: ChatVisibility
|
||||
|
||||
|
||||
class NewChatThreadRead(NewChatThreadBase, IDModel):
|
||||
"""
|
||||
Schema for reading a thread (matches assistant-ui ThreadRecord).
|
||||
"""
|
||||
|
||||
search_space_id: int
|
||||
visibility: ChatVisibility
|
||||
created_by_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
|
@ -116,6 +127,9 @@ class ThreadListItem(BaseModel):
|
|||
id: int
|
||||
title: str
|
||||
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")
|
||||
updated_at: datetime = Field(alias="updatedAt")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, SquarePlus } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -34,8 +34,13 @@ export default function DocumentsTable() {
|
|||
const t = useTranslations("documents");
|
||||
const id = useId();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
|
||||
const handleNewNote = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/editor/new`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounced(search, 250);
|
||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||
|
|
@ -238,10 +243,16 @@ export default function DocumentsTable() {
|
|||
<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>
|
||||
</div>
|
||||
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleNewNote} variant="default" size="sm">
|
||||
<SquarePlus className="w-4 h-4 mr-2" />
|
||||
{t("create_shared_note")}
|
||||
</Button>
|
||||
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
|
||||
|
|
|
|||
|
|
@ -267,21 +267,8 @@ export default function EditorPage() {
|
|||
setHasUnsavedChanges(false);
|
||||
toast.success("Note created successfully! Reindexing in background...");
|
||||
|
||||
// Invalidate notes query to refresh the sidebar
|
||||
queryClient.invalidateQueries({
|
||||
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(),
|
||||
});
|
||||
// Redirect to documents page after successful save
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
} else {
|
||||
// Existing document - save normally
|
||||
if (!editorContent) {
|
||||
|
|
@ -310,12 +297,8 @@ export default function EditorPage() {
|
|||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
|
||||
// Invalidate notes query when updating notes to refresh the sidebar
|
||||
if (isNote) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["notes", String(searchSpaceId)],
|
||||
});
|
||||
}
|
||||
// Redirect to documents page after successful save
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
|
|
@ -336,7 +319,7 @@ export default function EditorPage() {
|
|||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -346,12 +329,12 @@ export default function EditorPage() {
|
|||
setGlobalHasUnsavedChanges(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) {
|
||||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -392,7 +375,7 @@ export default function EditorPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/new-chat`)}
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -40,9 +40,12 @@ import {
|
|||
} from "@/lib/chat/podcast-state";
|
||||
import {
|
||||
appendMessage,
|
||||
type ChatVisibility,
|
||||
createThread,
|
||||
getThreadFull,
|
||||
getThreadMessages,
|
||||
type MessageRecord,
|
||||
type ThreadRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import {
|
||||
trackChatCreated,
|
||||
|
|
@ -217,6 +220,7 @@ export default function NewChatPage() {
|
|||
const queryClient = useQueryClient();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [threadId, setThreadId] = useState<number | null>(null);
|
||||
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
// Store thinking steps per message ID - kept separate from content to avoid
|
||||
|
|
@ -264,6 +268,7 @@ export default function NewChatPage() {
|
|||
// Reset all state when switching between chats to prevent stale data
|
||||
setMessages([]);
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
setMessageThinkingSteps(new Map());
|
||||
setMentionedDocumentIds([]);
|
||||
setMentionedDocuments([]);
|
||||
|
|
@ -272,11 +277,19 @@ export default function NewChatPage() {
|
|||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
// Thread exists - load messages
|
||||
// Thread exists - load thread data and messages
|
||||
setThreadId(urlChatId);
|
||||
const response = await getThreadMessages(urlChatId);
|
||||
if (response.messages && response.messages.length > 0) {
|
||||
const loadedMessages = response.messages.map(convertToThreadMessage);
|
||||
|
||||
// Load thread data (for visibility info) and messages in parallel
|
||||
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);
|
||||
|
||||
// Extract and restore thinking steps from persisted messages
|
||||
|
|
@ -284,7 +297,7 @@ export default function NewChatPage() {
|
|||
// Extract and restore mentioned documents from persisted messages
|
||||
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
||||
|
||||
for (const msg of response.messages) {
|
||||
for (const msg of messagesResponse.messages) {
|
||||
if (msg.role === "assistant") {
|
||||
const steps = extractThinkingSteps(msg.content);
|
||||
if (steps.length > 0) {
|
||||
|
|
@ -320,6 +333,7 @@ export default function NewChatPage() {
|
|||
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
||||
// that will cause 404 errors on subsequent API calls
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
toast.error("Failed to load chat. Please try again.");
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
|
|
@ -346,6 +360,19 @@ export default function NewChatPage() {
|
|||
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
|
||||
const onNew = useCallback(
|
||||
async (message: AppendMessage) => {
|
||||
|
|
@ -916,7 +943,13 @@ export default function NewChatPage() {
|
|||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||
header={
|
||||
<ChatHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
thread={currentThread}
|
||||
onThreadVisibilityChange={handleVisibilityChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { useParams, usePathname, useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
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 { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
|
|
@ -20,18 +19,16 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
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 { AllSearchSpacesSheet } from "../ui/sheets";
|
||||
import { LayoutShell } from "../ui/shell";
|
||||
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
|
||||
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
|
||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||
|
||||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
|
|
@ -58,16 +55,11 @@ export function LayoutDataProvider({
|
|||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
||||
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
||||
|
||||
// Current IDs from URL
|
||||
const currentChatId = params?.chat_id
|
||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: null;
|
||||
const currentNoteId = params?.note_id
|
||||
? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id)
|
||||
: null;
|
||||
|
||||
// Fetch current search space
|
||||
const { data: searchSpace } = useQuery({
|
||||
|
|
@ -77,42 +69,15 @@ export function LayoutDataProvider({
|
|||
});
|
||||
|
||||
// Fetch threads
|
||||
const { data: threadsData, refetch: refetchThreads } = useQuery({
|
||||
const { data: threadsData } = useQuery({
|
||||
queryKey: ["threads", searchSpaceId, { limit: 4 }],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// Fetch notes
|
||||
const { data: notesData, refetch: refetchNotes } = useQuery({
|
||||
queryKey: ["notes", searchSpaceId],
|
||||
queryFn: () =>
|
||||
notesApiService.getNotes({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
page_size: 4,
|
||||
}),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// Poll for active reindexing tasks to show inline loading indicators
|
||||
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);
|
||||
// Separate sidebar states for shared and private chats
|
||||
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
||||
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
||||
|
||||
// Search space sheet and dialog state
|
||||
const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false);
|
||||
|
|
@ -123,14 +88,6 @@ export function LayoutDataProvider({
|
|||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||
const [isDeletingChat, setIsDeletingChat] = useState(false);
|
||||
|
||||
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
|
||||
const [noteToDelete, setNoteToDelete] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
search_space_id: number;
|
||||
} | null>(null);
|
||||
const [isDeletingNote, setIsDeletingNote] = useState(false);
|
||||
|
||||
const searchSpaces: SearchSpace[] = useMemo(() => {
|
||||
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||
return searchSpacesData.map((space) => ({
|
||||
|
|
@ -149,35 +106,34 @@ export function LayoutDataProvider({
|
|||
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
|
||||
}, [searchSpaceId, searchSpaces]);
|
||||
|
||||
// Transform chats
|
||||
const chats: ChatItem[] = useMemo(() => {
|
||||
if (!threadsData?.threads) return [];
|
||||
return threadsData.threads.map((thread) => ({
|
||||
id: thread.id,
|
||||
name: thread.title || `Chat ${thread.id}`,
|
||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||
}));
|
||||
}, [threadsData, searchSpaceId]);
|
||||
// Transform and split chats into private and shared based on visibility
|
||||
const { myChats, sharedChats } = useMemo(() => {
|
||||
if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
|
||||
|
||||
// Transform notes
|
||||
const notes: NoteItem[] = useMemo(() => {
|
||||
if (!notesData?.items) return [];
|
||||
const sortedNotes = [...notesData.items].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;
|
||||
});
|
||||
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]);
|
||||
const privateChats: ChatItem[] = [];
|
||||
const sharedChatsList: ChatItem[] = [];
|
||||
|
||||
for (const thread of threadsData.threads) {
|
||||
const chatItem: ChatItem = {
|
||||
id: thread.id,
|
||||
name: thread.title || `Chat ${thread.id}`,
|
||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||
visibility: thread.visibility,
|
||||
isOwnThread: thread.is_own_thread,
|
||||
};
|
||||
|
||||
// Split based on visibility, not ownership:
|
||||
// - PRIVATE chats go to "Private Chats" section
|
||||
// - SEARCH_SPACE chats go to "Shared Chats" section
|
||||
if (thread.visibility === "SEARCH_SPACE") {
|
||||
sharedChatsList.push(chatItem);
|
||||
} else {
|
||||
privateChats.push(chatItem);
|
||||
}
|
||||
}
|
||||
|
||||
return { myChats: privateChats, sharedChats: sharedChatsList };
|
||||
}, [threadsData, searchSpaceId]);
|
||||
|
||||
// Navigation items
|
||||
const navItems: NavItem[] = useMemo(
|
||||
|
|
@ -264,34 +220,6 @@ export function LayoutDataProvider({
|
|||
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(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/settings`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
|
@ -318,12 +246,12 @@ export function LayoutDataProvider({
|
|||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
|
||||
const handleViewAllChats = useCallback(() => {
|
||||
setIsAllChatsSidebarOpen(true);
|
||||
const handleViewAllSharedChats = useCallback(() => {
|
||||
setIsAllSharedChatsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleViewAllNotes = useCallback(() => {
|
||||
setIsAllNotesSidebarOpen(true);
|
||||
const handleViewAllPrivateChats = useCallback(() => {
|
||||
setIsAllPrivateChatsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
// Delete handlers
|
||||
|
|
@ -345,24 +273,6 @@ export function LayoutDataProvider({
|
|||
}
|
||||
}, [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
|
||||
const pageUsage = user
|
||||
? {
|
||||
|
|
@ -384,18 +294,14 @@ export function LayoutDataProvider({
|
|||
searchSpace={activeSearchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
chats={myChats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={currentChatId}
|
||||
onNewChat={handleNewChat}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatDelete={handleChatDelete}
|
||||
onViewAllChats={handleViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={currentNoteId}
|
||||
onNoteSelect={handleNoteSelect}
|
||||
onNoteDelete={handleNoteDelete}
|
||||
onAddNote={handleAddNote}
|
||||
onViewAllNotes={handleViewAllNotes}
|
||||
onViewAllSharedChats={handleViewAllSharedChats}
|
||||
onViewAllPrivateChats={handleViewAllPrivateChats}
|
||||
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
|
||||
onSettings={handleSettings}
|
||||
onManageMembers={handleManageMembers}
|
||||
|
|
@ -455,19 +361,18 @@ export function LayoutDataProvider({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* All Chats Sidebar */}
|
||||
<AllChatsSidebar
|
||||
open={isAllChatsSidebarOpen}
|
||||
onOpenChange={setIsAllChatsSidebarOpen}
|
||||
{/* All Shared Chats Sidebar */}
|
||||
<AllSharedChatsSidebar
|
||||
open={isAllSharedChatsSidebarOpen}
|
||||
onOpenChange={setIsAllSharedChatsSidebarOpen}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
|
||||
{/* All Notes Sidebar */}
|
||||
<AllNotesSidebar
|
||||
open={isAllNotesSidebarOpen}
|
||||
onOpenChange={setIsAllNotesSidebarOpen}
|
||||
{/* All Private Chats Sidebar */}
|
||||
<AllPrivateChatsSidebar
|
||||
open={isAllPrivateChatsSidebarOpen}
|
||||
onOpenChange={setIsAllPrivateChatsSidebarOpen}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onAddNote={handleAddNote}
|
||||
/>
|
||||
|
||||
{/* All Search Spaces Sheet */}
|
||||
|
|
@ -489,49 +394,6 @@ export function LayoutDataProvider({
|
|||
open={isCreateSearchSpaceDialogOpen}
|
||||
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Note Dialog */}
|
||||
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>{t("delete_note")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
|
||||
{t("action_cannot_undone")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteNoteDialog(false)}
|
||||
disabled={isDeletingNote}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteNote}
|
||||
disabled={isDeletingNote}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeletingNote ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export interface ChatItem {
|
|||
name: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
visibility?: "PRIVATE" | "SEARCH_SPACE";
|
||||
isOwnThread?: boolean;
|
||||
}
|
||||
|
||||
export interface NoteItem {
|
||||
|
|
@ -76,16 +78,6 @@ export interface ChatsSectionProps {
|
|||
searchSpaceId?: string;
|
||||
}
|
||||
|
||||
export interface NotesSectionProps {
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
searchSpaceId?: string;
|
||||
}
|
||||
|
||||
export interface PageUsageDisplayProps {
|
||||
pagesUsed: number;
|
||||
pagesLimit: number;
|
||||
|
|
@ -107,17 +99,12 @@ export interface SidebarProps {
|
|||
searchSpaceId?: string;
|
||||
navItems: NavItem[];
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
theme?: string;
|
||||
onSettings?: () => void;
|
||||
|
|
|
|||
|
|
@ -5,14 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarState } from "../../hooks";
|
||||
import type {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { Header } from "../header";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
||||
|
|
@ -26,17 +19,13 @@ interface LayoutShellProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -63,17 +52,13 @@ export function LayoutShell({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -118,17 +103,13 @@ export function LayoutShell({
|
|||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onViewAllChats={onViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={activeNoteId}
|
||||
onNoteSelect={onNoteSelect}
|
||||
onNoteDelete={onNoteDelete}
|
||||
onAddNote={onAddNote}
|
||||
onViewAllNotes={onViewAllNotes}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
|
|
@ -167,17 +148,13 @@ export function LayoutShell({
|
|||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onViewAllChats={onViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={activeNoteId}
|
||||
onNoteSelect={onNoteSelect}
|
||||
onNoteDelete={onNoteDelete}
|
||||
onAddNote={onAddNote}
|
||||
onViewAllNotes={onViewAllNotes}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,21 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { format } from "date-fns";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
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, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -38,6 +41,8 @@ import {
|
|||
} from "@/lib/chat/thread-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TabType = "shared" | "private";
|
||||
|
||||
interface AllChatsSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -65,7 +70,7 @@ export function AllChatsSidebar({
|
|||
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
|
||||
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("shared");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||
|
|
@ -122,6 +127,34 @@ export function AllChatsSidebar({
|
|||
enabled: !!searchSpaceId && open && isSearchMode,
|
||||
});
|
||||
|
||||
// Split threads into shared and private based on visibility
|
||||
const { sharedChats, privateChats } = useMemo(() => {
|
||||
let allThreads: ThreadListItem[] = [];
|
||||
|
||||
if (isSearchMode) {
|
||||
allThreads = searchData ?? [];
|
||||
} else if (threadsData) {
|
||||
// Combine active and archived threads for filtering
|
||||
allThreads = [...threadsData.threads, ...threadsData.archived_threads];
|
||||
}
|
||||
|
||||
const shared: ThreadListItem[] = [];
|
||||
const privateChatsList: ThreadListItem[] = [];
|
||||
|
||||
for (const thread of allThreads) {
|
||||
if (thread.visibility === "SEARCH_SPACE") {
|
||||
shared.push(thread);
|
||||
} else {
|
||||
privateChatsList.push(thread);
|
||||
}
|
||||
}
|
||||
|
||||
return { sharedChats: shared, privateChats: privateChatsList };
|
||||
}, [threadsData, searchData, isSearchMode]);
|
||||
|
||||
// Get threads for current tab
|
||||
const threads = activeTab === "shared" ? sharedChats : privateChats;
|
||||
|
||||
// Handle thread navigation
|
||||
const handleThreadClick = useCallback(
|
||||
(threadId: number) => {
|
||||
|
|
@ -191,20 +224,12 @@ export function AllChatsSidebar({
|
|||
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 error = isSearchMode ? searchError : threadsError;
|
||||
|
||||
// Get counts for tabs
|
||||
const activeCount = threadsData?.threads.length ?? 0;
|
||||
const archivedCount = threadsData?.archived_threads.length ?? 0;
|
||||
const sharedCount = sharedChats.length;
|
||||
const privateCount = privateChats.length;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
|
|
@ -218,7 +243,7 @@ export function AllChatsSidebar({
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
@ -229,13 +254,13 @@ export function AllChatsSidebar({
|
|||
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"
|
||||
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_chats") || "All Chats"}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("all_chats") || "All Chats"}</h2>
|
||||
<Button
|
||||
|
|
@ -273,35 +298,35 @@ export function AllChatsSidebar({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab toggle for active/archived (only show when not searching) */}
|
||||
{!isSearchMode && (
|
||||
<div className="flex-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>
|
||||
)}
|
||||
{/* Tab toggle for shared/private chats */}
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("shared")}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors flex items-center justify-center gap-1.5",
|
||||
activeTab === "shared"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{t("shared_chats") || "Shared"} ({sharedCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("private")}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors flex items-center justify-center gap-1.5",
|
||||
activeTab === "private"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
{t("chats") || "Private"} ({privateCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
|
|
@ -320,6 +345,7 @@ export function AllChatsSidebar({
|
|||
const isArchiving = archivingThreadId === thread.id;
|
||||
const isBusy = isDeleting || isArchiving;
|
||||
const isActive = currentChatId === thread.id;
|
||||
const isShared = thread.visibility === "SEARCH_SPACE";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -329,7 +355,8 @@ export function AllChatsSidebar({
|
|||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
isBusy && "opacity-50 pointer-events-none",
|
||||
thread.archived && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{/* Main clickable area for navigation */}
|
||||
|
|
@ -343,13 +370,21 @@ export function AllChatsSidebar({
|
|||
>
|
||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||
{thread.archived && (
|
||||
<ArchiveIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<p>
|
||||
{t("updated") || "Updated"}:{" "}
|
||||
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
{t("updated") || "Updated"}:{" "}
|
||||
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
|
||||
</p>
|
||||
{thread.archived && (
|
||||
<p className="text-muted-foreground text-xs">Archived</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
|
@ -377,7 +412,7 @@ export function AllChatsSidebar({
|
|||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-[80]">
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
|
|
@ -420,16 +455,26 @@ export function AllChatsSidebar({
|
|||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<MessageCircleMore 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_chats") || "No chats yet"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
|
||||
</p>
|
||||
{activeTab === "shared" ? (
|
||||
<>
|
||||
<Users className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_shared_chats") || "No shared chats"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Share a chat to collaborate with your team
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_chats") || "No private chats"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,443 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
RotateCcwIcon,
|
||||
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 { 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 AllPrivateChatsSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function AllPrivateChatsSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllPrivateChatsSidebarProps) {
|
||||
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 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(
|
||||
(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("chats") || "Private Chats"}
|
||||
>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Lock 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_chats") || "No private chats"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Menu } from "lucide-react";
|
||||
import { Menu, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import type {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
|
|
@ -26,17 +18,13 @@ interface MobileSidebarProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -66,17 +54,13 @@ export function MobileSidebar({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -99,27 +83,37 @@ export function MobileSidebar({
|
|||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNoteSelect = (note: NoteItem) => {
|
||||
onNoteSelect(note);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<div className="shrink-0 border-r bg-muted/40">
|
||||
<ScrollArea className="h-full">
|
||||
<IconRail
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
</ScrollArea>
|
||||
{/* Horizontal Search Spaces Rail */}
|
||||
<div className="shrink-0 border-b bg-muted/40 px-2 py-2 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-1 py-1 overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/20">
|
||||
{searchSpaces.map((space) => (
|
||||
<div key={space.id} className="shrink-0">
|
||||
<SearchSpaceAvatar
|
||||
name={space.name}
|
||||
isActive={space.id === activeSearchSpaceId}
|
||||
onClick={() => handleSearchSpaceSelect(space.id)}
|
||||
size="md"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
|
|
@ -127,6 +121,7 @@ export function MobileSidebar({
|
|||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={() => {
|
||||
onNewChat();
|
||||
|
|
@ -134,13 +129,8 @@ export function MobileSidebar({
|
|||
}}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onViewAllChats={onViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={activeNoteId}
|
||||
onNoteSelect={handleNoteSelect}
|
||||
onNoteDelete={onNoteDelete}
|
||||
onAddNote={onAddNote}
|
||||
onViewAllNotes={onViewAllNotes}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react";
|
||||
import { FolderOpen, MessageSquare, PenSquare } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { NavSection } from "./NavSection";
|
||||
import { NoteListItem } from "./NoteListItem";
|
||||
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
|
|
@ -30,17 +22,13 @@ interface SidebarProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -58,17 +46,13 @@ export function Sidebar({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats = [],
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -143,7 +127,7 @@ export function Sidebar({
|
|||
<ScrollArea className="flex-1">
|
||||
{isCollapsed ? (
|
||||
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
|
||||
{chats.length > 0 && (
|
||||
{(chats.length > 0 || sharedChats.length > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -153,52 +137,78 @@ export function Sidebar({
|
|||
onClick={() => onToggleCollapse?.()}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="sr-only">{t("recent_chats")}</span>
|
||||
<span className="sr-only">{t("chats")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("recent_chats")} ({chats.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})
|
||||
{t("chats")} ({chats.length + sharedChats.length})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 py-2 w-[240px]">
|
||||
{/* Shared Chats Section */}
|
||||
<SidebarSection
|
||||
title={t("recent_chats")}
|
||||
title={t("shared_chats")}
|
||||
defaultOpen={true}
|
||||
action={
|
||||
onViewAllChats && chats.length > 0 ? (
|
||||
onViewAllSharedChats ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onViewAllChats}
|
||||
onClick={onViewAllSharedChats}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</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>
|
||||
) : undefined
|
||||
}
|
||||
|
|
@ -216,67 +226,7 @@ export function Sidebar({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_recent_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>
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_chats")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export { AllChatsSidebar } from "./AllChatsSidebar";
|
||||
export { AllNotesSidebar } from "./AllNotesSidebar";
|
||||
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
|
|
|
|||
|
|
@ -5,14 +5,18 @@ import type {
|
|||
GlobalNewLLMConfig,
|
||||
NewLLMConfigPublic,
|
||||
} 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 { ModelSelector } from "./model-selector";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
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 [selectedConfig, setSelectedConfig] = useState<
|
||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||
|
|
@ -46,8 +50,9 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||
<ChatShareButton thread={thread ?? null} onVisibilityChange={onThreadVisibilityChange} />
|
||||
<ModelConfigSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={handleSidebarClose}
|
||||
|
|
@ -56,6 +61,6 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
searchSpaceId={searchSpaceId}
|
||||
mode={sidebarMode}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
203
surfsense_web/components/new-chat/chat-share-button.tsx
Normal file
203
surfsense_web/components/new-chat/chat-share-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ enum ResponseType {
|
|||
}
|
||||
|
||||
export type RequestOptions = {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
contentType?: "application/json" | "application/x-www-form-urlencoded";
|
||||
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">) {
|
||||
return this.request(url, undefined, {
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,17 @@ import { baseApiService } from "@/lib/apis/base-api.service";
|
|||
// Types matching backend schemas
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Chat visibility levels - matches backend ChatVisibility enum
|
||||
*/
|
||||
export type ChatVisibility = "PRIVATE" | "SEARCH_SPACE";
|
||||
|
||||
export interface ThreadRecord {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
visibility: ChatVisibility;
|
||||
created_by_id: string | null;
|
||||
search_space_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -35,6 +42,9 @@ export interface ThreadListItem {
|
|||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
visibility: ChatVisibility;
|
||||
created_by_id: string | null;
|
||||
is_own_thread: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -127,6 +137,25 @@ export async function deleteThread(threadId: number): Promise<void> {
|
|||
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)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@
|
|||
"rows_per_page": "Rows per page",
|
||||
"refresh": "Refresh",
|
||||
"refresh_success": "Documents refreshed",
|
||||
"create_shared_note": "Create Shared Note",
|
||||
"processing_documents": "Processing documents...",
|
||||
"active_tasks_count": "{count} active task(s)"
|
||||
},
|
||||
|
|
@ -628,11 +629,16 @@
|
|||
"manage": "Manage"
|
||||
},
|
||||
"sidebar": {
|
||||
"chats": "Private Chats",
|
||||
"shared_chats": "Shared Chats",
|
||||
"recent_chats": "Recent Chats",
|
||||
"search_chats": "Search chats...",
|
||||
"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",
|
||||
"view_all_private_chats": "View all private chats",
|
||||
"all_chats": "All Chats",
|
||||
"all_chats_description": "Browse and manage all your chats",
|
||||
"no_chats": "No chats yet",
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@
|
|||
"rows_per_page": "每页行数",
|
||||
"refresh": "刷新",
|
||||
"refresh_success": "文档已刷新",
|
||||
"create_shared_note": "创建共享笔记",
|
||||
"processing_documents": "正在处理文档...",
|
||||
"active_tasks_count": "{count} 个正在进行的工作项"
|
||||
},
|
||||
|
|
@ -628,11 +629,16 @@
|
|||
"manage": "管理"
|
||||
},
|
||||
"sidebar": {
|
||||
"chats": "私人对话",
|
||||
"shared_chats": "共享对话",
|
||||
"recent_chats": "最近对话",
|
||||
"search_chats": "搜索对话...",
|
||||
"no_chats_found": "未找到对话",
|
||||
"no_recent_chats": "暂无最近对话",
|
||||
"no_shared_chats": "暂无共享对话",
|
||||
"view_all_chats": "查看所有对话",
|
||||
"view_all_shared_chats": "查看所有共享对话",
|
||||
"view_all_private_chats": "查看所有私人对话",
|
||||
"all_chats": "所有对话",
|
||||
"all_chats_description": "浏览和管理您的所有对话",
|
||||
"no_chats": "暂无对话",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue