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