Merge branch 'dev' into implement-surfsense-docs-mentions

This commit is contained in:
Rohan Verma 2026-01-13 00:55:16 -08:00 committed by GitHub
commit 720b339702
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1404 additions and 1025 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,9 +2,9 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { RefreshCw } from "lucide-react";
import { RefreshCw, SquarePlus } from "lucide-react";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@ -34,8 +34,13 @@ export default function DocumentsTable() {
const t = useTranslations("documents");
const id = useId();
const params = useParams();
const router = useRouter();
const searchSpaceId = Number(params.search_space_id);
const handleNewNote = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/editor/new`);
}, [router, searchSpaceId]);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
@ -349,10 +354,16 @@ export default function DocumentsTable() {
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")}
</Button>
<div className="flex items-center gap-2">
<Button onClick={handleNewNote} variant="default" size="sm">
<SquarePlus className="w-4 h-4 mr-2" />
{t("create_shared_note")}
</Button>
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")}
</Button>
</div>
</motion.div>
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />

View file

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

View file

@ -40,9 +40,12 @@ import {
} from "@/lib/chat/podcast-state";
import {
appendMessage,
type ChatVisibility,
createThread,
getThreadFull,
getThreadMessages,
type MessageRecord,
type ThreadRecord,
} from "@/lib/chat/thread-persistence";
import {
trackChatCreated,
@ -217,6 +220,7 @@ export default function NewChatPage() {
const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true);
const [threadId, setThreadId] = useState<number | null>(null);
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
const [isRunning, setIsRunning] = useState(false);
// Store thinking steps per message ID - kept separate from content to avoid
@ -264,6 +268,7 @@ export default function NewChatPage() {
// Reset all state when switching between chats to prevent stale data
setMessages([]);
setThreadId(null);
setCurrentThread(null);
setMessageThinkingSteps(new Map());
setMentionedDocumentIds({
surfsense_doc_ids: [],
@ -275,11 +280,19 @@ export default function NewChatPage() {
try {
if (urlChatId > 0) {
// Thread exists - load messages
// Thread exists - load thread data and messages
setThreadId(urlChatId);
const response = await getThreadMessages(urlChatId);
if (response.messages && response.messages.length > 0) {
const loadedMessages = response.messages.map(convertToThreadMessage);
// Load thread data (for visibility info) and messages in parallel
const [threadData, messagesResponse] = await Promise.all([
getThreadFull(urlChatId),
getThreadMessages(urlChatId),
]);
setCurrentThread(threadData);
if (messagesResponse.messages && messagesResponse.messages.length > 0) {
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage);
setMessages(loadedMessages);
// Extract and restore thinking steps from persisted messages
@ -287,7 +300,7 @@ export default function NewChatPage() {
// Extract and restore mentioned documents from persisted messages
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
for (const msg of response.messages) {
for (const msg of messagesResponse.messages) {
if (msg.role === "assistant") {
const steps = extractThinkingSteps(msg.content);
if (steps.length > 0) {
@ -323,6 +336,7 @@ export default function NewChatPage() {
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls
setThreadId(null);
setCurrentThread(null);
toast.error("Failed to load chat. Please try again.");
} finally {
setIsInitializing(false);
@ -349,6 +363,19 @@ export default function NewChatPage() {
setIsRunning(false);
}, []);
// Handle visibility change from ChatShareButton
const handleVisibilityChange = useCallback(
(newVisibility: ChatVisibility) => {
setCurrentThread((prev) => (prev ? { ...prev, visibility: newVisibility } : null));
// Refetch all thread queries so sidebar reflects the change immediately
// Use predicate to match any query that starts with "threads"
queryClient.refetchQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
});
},
[queryClient]
);
// Handle new message from user
const onNew = useCallback(
async (message: AppendMessage) => {
@ -924,7 +951,13 @@ export default function NewChatPage() {
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
header={
<ChatHeader
searchSpaceId={searchSpaceId}
thread={currentThread}
onThreadVisibilityChange={handleVisibilityChange}
/>
}
/>
</div>
</AssistantRuntimeProvider>

View file

@ -4,7 +4,6 @@ export type {
ChatItem,
IconRailProps,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
SidebarSectionProps,
@ -20,7 +19,6 @@ export {
MobileSidebarTrigger,
NavIcon,
NavSection,
NoteListItem,
PageUsageDisplay,
SearchSpaceAvatar,
Sidebar,

View file

@ -1,13 +1,12 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue } from "jotai";
import { Logs, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
@ -20,17 +19,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useLogsSummary } from "@/hooks/use-logs";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
@ -57,16 +54,11 @@ export function LayoutDataProvider({
const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
// Current IDs from URL
const currentChatId = params?.chat_id
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null;
const currentNoteId = params?.note_id
? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id)
: null;
// Fetch current search space
const { data: searchSpace } = useQuery({
@ -76,42 +68,15 @@ export function LayoutDataProvider({
});
// Fetch threads
const { data: threadsData, refetch: refetchThreads } = useQuery({
const { data: threadsData } = useQuery({
queryKey: ["threads", searchSpaceId, { limit: 4 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
enabled: !!searchSpaceId,
});
// Fetch notes
const { data: notesData, refetch: refetchNotes } = useQuery({
queryKey: ["notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
page_size: 4,
}),
enabled: !!searchSpaceId,
});
// Poll for active reindexing tasks to show inline loading indicators
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
enablePolling: true,
refetchInterval: 5000,
});
// Create a Set of document IDs that are currently being reindexed
const reindexingDocumentIds = useMemo(() => {
if (!summary?.active_tasks) return new Set<number>();
return new Set(
summary.active_tasks
.filter((task) => task.document_id != null)
.map((task) => task.document_id as number)
);
}, [summary?.active_tasks]);
// All chats/notes sidebars state
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
// Separate sidebar states for shared and private chats
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
// Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
@ -121,14 +86,6 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
const [noteToDelete, setNoteToDelete] = useState<{
id: number;
name: string;
search_space_id: number;
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
@ -147,35 +104,34 @@ export function LayoutDataProvider({
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
}, [searchSpaceId, searchSpaces]);
// Transform chats
const chats: ChatItem[] = useMemo(() => {
if (!threadsData?.threads) return [];
return threadsData.threads.map((thread) => ({
id: thread.id,
name: thread.title || `Chat ${thread.id}`,
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
}));
}, [threadsData, searchSpaceId]);
// Transform and split chats into private and shared based on visibility
const { myChats, sharedChats } = useMemo(() => {
if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
// Transform notes
const notes: NoteItem[] = useMemo(() => {
if (!notesData?.items) return [];
const sortedNotes = [...notesData.items].sort((a, b) => {
const dateA = a.updated_at
? new Date(a.updated_at).getTime()
: new Date(a.created_at).getTime();
const dateB = b.updated_at
? new Date(b.updated_at).getTime()
: new Date(b.created_at).getTime();
return dateB - dateA;
});
return sortedNotes.slice(0, 4).map((note) => ({
id: note.id,
name: note.title,
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
isReindexing: reindexingDocumentIds.has(note.id),
}));
}, [notesData, reindexingDocumentIds]);
const privateChats: ChatItem[] = [];
const sharedChatsList: ChatItem[] = [];
for (const thread of threadsData.threads) {
const chatItem: ChatItem = {
id: thread.id,
name: thread.title || `Chat ${thread.id}`,
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
visibility: thread.visibility,
isOwnThread: thread.is_own_thread,
};
// Split based on visibility, not ownership:
// - PRIVATE chats go to "Private Chats" section
// - SEARCH_SPACE chats go to "Shared Chats" section
if (thread.visibility === "SEARCH_SPACE") {
sharedChatsList.push(chatItem);
} else {
privateChats.push(chatItem);
}
}
return { myChats: privateChats, sharedChats: sharedChatsList };
}, [threadsData, searchSpaceId]);
// Navigation items
const navItems: NavItem[] = useMemo(
@ -258,34 +214,6 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true);
}, []);
const handleNoteSelect = useCallback(
(note: NoteItem) => {
if (hasUnsavedEditorChanges) {
setPendingNavigation(note.url);
} else {
router.push(note.url);
}
},
[router, hasUnsavedEditorChanges, setPendingNavigation]
);
const handleNoteDelete = useCallback(
(note: NoteItem) => {
setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) });
setShowDeleteNoteDialog(true);
},
[searchSpaceId]
);
const handleAddNote = useCallback(() => {
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
if (hasUnsavedEditorChanges) {
setPendingNavigation(newNoteUrl);
} else {
router.push(newNoteUrl);
}
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings`);
}, [router, searchSpaceId]);
@ -312,12 +240,12 @@ export function LayoutDataProvider({
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const handleViewAllChats = useCallback(() => {
setIsAllChatsSidebarOpen(true);
const handleViewAllSharedChats = useCallback(() => {
setIsAllSharedChatsSidebarOpen(true);
}, []);
const handleViewAllNotes = useCallback(() => {
setIsAllNotesSidebarOpen(true);
const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true);
}, []);
// Delete handlers
@ -339,24 +267,6 @@ export function LayoutDataProvider({
}
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
const confirmDeleteNote = useCallback(async () => {
if (!noteToDelete) return;
setIsDeletingNote(true);
try {
await notesApiService.deleteNote({
search_space_id: noteToDelete.search_space_id,
note_id: noteToDelete.id,
});
refetchNotes();
} catch (error) {
console.error("Error deleting note:", error);
} finally {
setIsDeletingNote(false);
setShowDeleteNoteDialog(false);
setNoteToDelete(null);
}
}, [noteToDelete, refetchNotes]);
// Page usage
const pageUsage = user
? {
@ -378,18 +288,14 @@ export function LayoutDataProvider({
searchSpace={activeSearchSpace}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
chats={myChats}
sharedChats={sharedChats}
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatDelete={handleChatDelete}
onViewAllChats={handleViewAllChats}
notes={notes}
activeNoteId={currentNoteId}
onNoteSelect={handleNoteSelect}
onNoteDelete={handleNoteDelete}
onAddNote={handleAddNote}
onViewAllNotes={handleViewAllNotes}
onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings}
onManageMembers={handleManageMembers}
@ -448,19 +354,18 @@ export function LayoutDataProvider({
</DialogContent>
</Dialog>
{/* All Chats Sidebar */}
<AllChatsSidebar
open={isAllChatsSidebarOpen}
onOpenChange={setIsAllChatsSidebarOpen}
{/* All Shared Chats Sidebar */}
<AllSharedChatsSidebar
open={isAllSharedChatsSidebarOpen}
onOpenChange={setIsAllSharedChatsSidebarOpen}
searchSpaceId={searchSpaceId}
/>
{/* All Notes Sidebar */}
<AllNotesSidebar
open={isAllNotesSidebarOpen}
onOpenChange={setIsAllNotesSidebarOpen}
{/* All Private Chats Sidebar */}
<AllPrivateChatsSidebar
open={isAllPrivateChatsSidebarOpen}
onOpenChange={setIsAllPrivateChatsSidebarOpen}
searchSpaceId={searchSpaceId}
onAddNote={handleAddNote}
/>
{/* Create Search Space Dialog */}
@ -468,49 +373,6 @@ export function LayoutDataProvider({
open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
{/* Delete Note Dialog */}
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_note")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
{t("action_cannot_undone")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteNoteDialog(false)}
disabled={isDeletingNote}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteNote}
disabled={isDeletingNote}
className="gap-2"
>
{isDeletingNote ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

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

View file

@ -7,7 +7,6 @@ export {
MobileSidebar,
MobileSidebarTrigger,
NavSection,
NoteListItem,
PageUsageDisplay,
Sidebar,
SidebarCollapseButton,

View file

@ -5,14 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useSidebarState } from "../../hooks";
import type {
ChatItem,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
} from "../../types/layout.types";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { Header } from "../header";
import { IconRail } from "../icon-rail";
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
@ -26,17 +19,13 @@ interface LayoutShellProps {
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void;
notes: NoteItem[];
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
@ -62,17 +51,13 @@ export function LayoutShell({
navItems,
onNavItemClick,
chats,
sharedChats,
activeChatId,
onNewChat,
onChatSelect,
onChatDelete,
onViewAllChats,
notes,
activeNoteId,
onNoteSelect,
onNoteDelete,
onAddNote,
onViewAllNotes,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
onSettings,
onManageMembers,
@ -116,17 +101,13 @@ export function LayoutShell({
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats}
notes={notes}
activeNoteId={activeNoteId}
onNoteSelect={onNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
@ -164,17 +145,13 @@ export function LayoutShell({
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats}
notes={notes}
activeNoteId={activeNoteId}
onNoteSelect={onNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -311,6 +311,7 @@
"rows_per_page": "Rows per page",
"refresh": "Refresh",
"refresh_success": "Documents refreshed",
"create_shared_note": "Create Shared Note",
"processing_documents": "Processing documents...",
"active_tasks_count": "{count} active task(s)"
},
@ -627,37 +628,23 @@
"manage": "Manage"
},
"sidebar": {
"recent_chats": "Recent Chats",
"chats": "Private Chats",
"shared_chats": "Shared Chats",
"search_chats": "Search chats...",
"no_chats_found": "No chats found",
"no_recent_chats": "No recent chats",
"view_all_chats": "View all chats",
"all_chats": "All Chats",
"all_chats_description": "Browse and manage all your chats",
"no_shared_chats": "No shared chats",
"view_all_shared_chats": "View all shared chats",
"view_all_private_chats": "View all private chats",
"no_chats": "No chats yet",
"start_new_chat_hint": "Start a new chat",
"error_loading_chats": "Error loading chats",
"chat_deleted": "Chat deleted successfully",
"error_deleting_chat": "Failed to delete chat",
"search_space": "Search Space",
"notes": "Notes",
"all_notes": "All Notes",
"all_notes_description": "Browse and manage all your notes",
"search_notes": "Search notes...",
"no_results_found": "No notes found",
"try_different_search": "Try a different search term",
"no_notes": "No notes yet",
"create_new_note": "Create a new note",
"error_loading_notes": "Error loading notes",
"loading": "Loading...",
"deleting": "Deleting...",
"delete": "Delete",
"created": "Created",
"try_different_search": "Try a different search term",
"updated": "Updated",
"more_options": "More options",
"clear_search": "Clear search",
"view_all_notes": "View all notes",
"add_note": "Add note",
"archive": "Archive",
"unarchive": "Restore",
"chat_archived": "Chat archived",

View file

@ -311,6 +311,7 @@
"rows_per_page": "每页行数",
"refresh": "刷新",
"refresh_success": "文档已刷新",
"create_shared_note": "创建共享笔记",
"processing_documents": "正在处理文档...",
"active_tasks_count": "{count} 个正在进行的工作项"
},
@ -627,37 +628,29 @@
"manage": "管理"
},
"sidebar": {
"recent_chats": "最近对话",
"chats": "私人对话",
"shared_chats": "共享对话",
"search_chats": "搜索对话...",
"no_chats_found": "未找到对话",
"no_recent_chats": "暂无最近对话",
"view_all_chats": "查看所有对话",
"all_chats": "所有对话",
"all_chats_description": "浏览和管理您的所有对话",
"no_shared_chats": "暂无共享对话",
"view_all_shared_chats": "查看所有共享对话",
"view_all_private_chats": "查看所有私人对话",
"no_chats": "暂无对话",
"start_new_chat_hint": "开始新对话",
"error_loading_chats": "加载对话时出错",
"chat_deleted": "对话删除成功",
"error_deleting_chat": "删除对话失败",
"search_space": "搜索空间",
"notes": "笔记",
"all_notes": "所有笔记",
"all_notes_description": "浏览和管理您的所有笔记",
"search_notes": "搜索笔记...",
"no_results_found": "未找到笔记",
"try_different_search": "尝试其他搜索词",
"no_notes": "暂无笔记",
"create_new_note": "创建新笔记",
"error_loading_notes": "加载笔记时出错",
"loading": "加载中...",
"deleting": "删除中...",
"delete": "删除",
"created": "创建时间",
"try_different_search": "尝试其他搜索词",
"updated": "更新时间",
"more_options": "更多选项",
"clear_search": "清除搜索",
"view_all_notes": "查看所有笔记",
"add_note": "添加笔记",
"archive": "归档",
"unarchive": "恢复",
"chat_archived": "对话已归档",
"chat_unarchived": "对话已恢复",
"no_archived_chats": "暂无已归档对话",
"error_archiving_chat": "归档对话失败",
"new_chat": "新对话",
"select_search_space": "选择搜索空间",
"manage_members": "管理成员",