Merge pull request #993 from MODSetter/dev_mod

feat: add folder management features including creation, deletion, an…
This commit is contained in:
Rohan Verma 2026-03-27 01:40:41 -07:00 committed by GitHub
commit f263cf91a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 7475 additions and 4330 deletions

1
deepagents Submodule

@ -0,0 +1 @@
Subproject commit a32ce7ff6b2112cf48170d2279a1953eded61987

View file

@ -37,7 +37,9 @@ def upgrade() -> None:
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'video_presentations'")
sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'video_presentations'"
)
)
if not result.fetchone():
op.create_table(

View file

@ -0,0 +1,90 @@
"""Add folders table and folder_id to documents
Revision ID: 109
Revises: 108
Creates the folders table for nested folder organization (max 8 levels),
adds folder_id FK to documents, and creates an expression-based unique
index to correctly handle NULL parent_id at root level.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "109"
down_revision: str | None = "108"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"folders",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("name", sa.String(255), nullable=False, index=True),
sa.Column("position", sa.String(50), nullable=False, index=True),
sa.Column(
"parent_id",
sa.Integer(),
sa.ForeignKey("folders.id", ondelete="CASCADE"),
nullable=True,
index=True,
),
sa.Column(
"search_space_id",
sa.Integer(),
sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"created_by_id",
sa.Uuid(),
sa.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
# Expression-based unique index: COALESCE(parent_id, 0) handles NULL correctly.
# PostgreSQL treats NULL != NULL in regular unique constraints, so a standard
# UniqueConstraint(search_space_id, parent_id, name) would allow duplicate
# folder names at the root level.
op.execute(
"""
CREATE UNIQUE INDEX uq_folder_space_parent_name
ON folders (search_space_id, COALESCE(parent_id, 0), name);
"""
)
op.add_column(
"documents",
sa.Column(
"folder_id",
sa.Integer(),
sa.ForeignKey("folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
)
def downgrade() -> None:
op.drop_column("documents", "folder_id")
op.execute("DROP INDEX IF EXISTS uq_folder_space_parent_name;")
op.drop_table("folders")

View file

@ -914,6 +914,43 @@ class SharedMemory(BaseModel, TimestampMixin):
created_by = relationship("User")
class Folder(BaseModel, TimestampMixin):
__tablename__ = "folders"
name = Column(String(255), nullable=False, index=True)
position = Column(String(50), nullable=False, index=True)
parent_id = Column(
Integer,
ForeignKey("folders.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
search_space_id = Column(
Integer,
ForeignKey("searchspaces.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
updated_at = Column(
TIMESTAMP(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
index=True,
)
parent = relationship("Folder", remote_side="Folder.id", backref="children")
search_space = relationship("SearchSpace", back_populates="folders")
created_by = relationship("User", back_populates="folders")
documents = relationship("Document", back_populates="folder", passive_deletes=True)
class Document(BaseModel, TimestampMixin):
__tablename__ = "documents"
@ -947,6 +984,13 @@ class Document(BaseModel, TimestampMixin):
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
)
folder_id = Column(
Integer,
ForeignKey("folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Track who created/uploaded this document
created_by_id = Column(
UUID(as_uuid=True),
@ -976,6 +1020,7 @@ class Document(BaseModel, TimestampMixin):
# Relationships
search_space = relationship("SearchSpace", back_populates="documents")
folder = relationship("Folder", back_populates="documents")
created_by = relationship("User", back_populates="documents")
connector = relationship("SearchSourceConnector", back_populates="documents")
chunks = relationship(
@ -1279,6 +1324,12 @@ class SearchSpace(BaseModel, TimestampMixin):
)
user = relationship("User", back_populates="search_spaces")
folders = relationship(
"Folder",
back_populates="search_space",
order_by="Folder.position",
cascade="all, delete-orphan",
)
documents = relationship(
"Document",
back_populates="search_space",
@ -1765,6 +1816,13 @@ if config.AUTH_TYPE == "GOOGLE":
passive_deletes=True,
)
# Folders created by this user
folders = relationship(
"Folder",
back_populates="created_by",
passive_deletes=True,
)
# Image generations created by this user
image_generations = relationship(
"ImageGeneration",
@ -1867,6 +1925,13 @@ else:
passive_deletes=True,
)
# Folders created by this user
folders = relationship(
"Folder",
back_populates="created_by",
passive_deletes=True,
)
# Image generations created by this user
image_generations = relationship(
"ImageGeneration",

View file

@ -11,6 +11,7 @@ from .confluence_add_connector_route import router as confluence_add_connector_r
from .discord_add_connector_route import router as discord_add_connector_router
from .documents_routes import router as documents_router
from .editor_routes import router as editor_router
from .folders_routes import router as folders_router
from .google_calendar_add_connector_route import (
router as google_calendar_add_connector_router,
)
@ -51,6 +52,7 @@ router.include_router(search_spaces_router)
router.include_router(rbac_router) # RBAC routes for roles, members, invites
router.include_router(editor_router)
router.include_router(documents_router)
router.include_router(folders_router)
router.include_router(notes_router)
router.include_router(new_chat_router) # Chat with assistant-ui persistence
router.include_router(sandbox_router) # Sandbox file downloads (Daytona)

View file

@ -320,6 +320,7 @@ async def read_documents(
page_size: int = 50,
search_space_id: int | None = None,
document_types: str | None = None,
folder_id: int | str | None = None,
sort_by: str = "created_at",
sort_order: str = "desc",
session: AsyncSession = Depends(get_async_session),
@ -391,6 +392,17 @@ async def read_documents(
query = query.filter(Document.document_type.in_(type_list))
count_query = count_query.filter(Document.document_type.in_(type_list))
# Filter by folder_id: "root" or "null" => root level (folder_id IS NULL),
# integer => specific folder, omitted => all documents
if folder_id is not None:
if str(folder_id).lower() in ("root", "null"):
query = query.filter(Document.folder_id.is_(None))
count_query = count_query.filter(Document.folder_id.is_(None))
else:
fid = int(folder_id)
query = query.filter(Document.folder_id == fid)
count_query = count_query.filter(Document.folder_id == fid)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
@ -451,6 +463,7 @@ async def read_documents(
created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
folder_id=doc.folder_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
created_by_email=created_by_email,
@ -608,6 +621,7 @@ async def search_documents(
created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
folder_id=doc.folder_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
created_by_email=created_by_email,
@ -978,6 +992,7 @@ async def read_document(
created_at=document.created_at,
updated_at=document.updated_at,
search_space_id=document.search_space_id,
folder_id=document.folder_id,
)
except HTTPException:
raise
@ -1036,6 +1051,7 @@ async def update_document(
created_at=db_document.created_at,
updated_at=db_document.updated_at,
search_space_id=db_document.search_space_id,
folder_id=db_document.folder_id,
)
except HTTPException:
raise

View file

@ -0,0 +1,516 @@
"""API routes for folder CRUD, move, reorder, and document move operations."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.db import Document, Folder, Permission, User, get_async_session
from app.schemas import (
BulkDocumentMove,
DocumentMove,
FolderBreadcrumb,
FolderCreate,
FolderMove,
FolderRead,
FolderReorder,
FolderUpdate,
)
from app.services.folder_service import (
check_no_circular_reference,
generate_folder_position,
get_folder_subtree_ids,
get_subtree_max_depth,
validate_folder_depth,
)
from app.users import current_active_user
from app.utils.rbac import check_permission
router = APIRouter()
@router.post("/folders", response_model=FolderRead)
async def create_folder(
request: FolderCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Create a new folder. Requires DOCUMENTS_CREATE permission."""
try:
await check_permission(
session,
user,
request.search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create folders in this search space",
)
if request.parent_id is not None:
parent = await session.get(Folder, request.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
if parent.search_space_id != request.search_space_id:
raise HTTPException(
status_code=400,
detail="Parent folder belongs to a different search space",
)
await validate_folder_depth(session, request.parent_id)
position = await generate_folder_position(
session, request.search_space_id, request.parent_id
)
folder = Folder(
name=request.name,
position=position,
parent_id=request.parent_id,
search_space_id=request.search_space_id,
created_by_id=user.id,
)
session.add(folder)
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
if "uq_folder_space_parent_name" in str(e):
raise HTTPException(
status_code=409,
detail="A folder with this name already exists at this location",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to create folder: {e!s}"
) from e
@router.get("/folders", response_model=list[FolderRead])
async def list_folders(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""List all folders in a search space (flat). Requires DOCUMENTS_READ permission."""
try:
await check_permission(
session,
user,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
)
result = await session.execute(
select(Folder)
.where(Folder.search_space_id == search_space_id)
.order_by(Folder.position)
)
return result.scalars().all()
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to list folders: {e!s}"
) from e
@router.get("/folders/{folder_id}", response_model=FolderRead)
async def get_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Get a single folder. Requires DOCUMENTS_READ permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
user,
folder.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
)
return folder
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to get folder: {e!s}"
) from e
@router.get("/folders/{folder_id}/breadcrumb", response_model=list[FolderBreadcrumb])
async def get_folder_breadcrumb(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Get ancestor chain for breadcrumb display. Requires DOCUMENTS_READ permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
user,
folder.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
)
result = await session.execute(
text("""
WITH RECURSIVE ancestors AS (
SELECT id, name, parent_id, 0 AS depth
FROM folders WHERE id = :folder_id
UNION ALL
SELECT f.id, f.name, f.parent_id, a.depth + 1
FROM folders f JOIN ancestors a ON f.id = a.parent_id
)
SELECT id, name FROM ancestors ORDER BY depth DESC;
"""),
{"folder_id": folder_id},
)
rows = result.fetchall()
return [FolderBreadcrumb(id=row.id, name=row.name) for row in rows]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to get breadcrumb: {e!s}"
) from e
@router.put("/folders/{folder_id}", response_model=FolderRead)
async def update_folder(
folder_id: int,
request: FolderUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Rename a folder. Requires DOCUMENTS_UPDATE permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
user,
folder.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update folders in this search space",
)
folder.name = request.name
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
if "uq_folder_space_parent_name" in str(e):
raise HTTPException(
status_code=409,
detail="A folder with this name already exists at this location",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to update folder: {e!s}"
) from e
@router.put("/folders/{folder_id}/move", response_model=FolderRead)
async def move_folder(
folder_id: int,
request: FolderMove,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Move a folder to a new parent. Requires DOCUMENTS_UPDATE permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
user,
folder.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move folders in this search space",
)
if request.new_parent_id is not None:
new_parent = await session.get(Folder, request.new_parent_id)
if not new_parent:
raise HTTPException(
status_code=404, detail="Target parent folder not found"
)
if new_parent.search_space_id != folder.search_space_id:
raise HTTPException(
status_code=400,
detail="Cannot move folder to a different search space",
)
await check_no_circular_reference(session, folder_id, request.new_parent_id)
subtree_depth = await get_subtree_max_depth(session, folder_id)
await validate_folder_depth(session, request.new_parent_id, subtree_depth)
position = await generate_folder_position(
session, folder.search_space_id, request.new_parent_id
)
folder.parent_id = request.new_parent_id
folder.position = position
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
if "uq_folder_space_parent_name" in str(e):
raise HTTPException(
status_code=409,
detail="A folder with this name already exists at the target location",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to move folder: {e!s}"
) from e
@router.put("/folders/{folder_id}/reorder", response_model=FolderRead)
async def reorder_folder(
folder_id: int,
request: FolderReorder,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Reorder a folder among its siblings via fractional indexing. Requires DOCUMENTS_UPDATE."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
user,
folder.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to reorder folders in this search space",
)
position = await generate_folder_position(
session,
folder.search_space_id,
folder.parent_id,
before_position=request.before_position,
after_position=request.after_position,
)
folder.position = position
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to reorder folder: {e!s}"
) from e
@router.delete("/folders/{folder_id}")
async def delete_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Delete a folder and cascade-delete subfolders. Documents are async-deleted via Celery."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
user,
folder.search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete folders in this search space",
)
subtree_ids = await get_folder_subtree_ids(session, folder_id)
doc_result = await session.execute(
select(Document.id).where(
Document.folder_id.in_(subtree_ids),
Document.status["state"].as_string() != "deleting",
)
)
document_ids = list(doc_result.scalars().all())
if document_ids:
await session.execute(
Document.__table__.update()
.where(Document.id.in_(document_ids))
.values(status={"state": "deleting"})
)
await session.commit()
await session.execute(Folder.__table__.delete().where(Folder.id == folder_id))
await session.commit()
if document_ids:
try:
from app.tasks.celery_tasks.document_tasks import (
delete_folder_documents_task,
)
delete_folder_documents_task.delay(document_ids)
except Exception as err:
await session.execute(
Document.__table__.update()
.where(Document.id.in_(document_ids))
.values(status={"state": "ready"})
)
await session.commit()
raise HTTPException(
status_code=503,
detail="Folder deleted but document cleanup could not be queued. Documents have been restored.",
) from err
return {
"message": "Folder deleted successfully",
"documents_queued_for_deletion": len(document_ids),
}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to delete folder: {e!s}"
) from e
@router.put("/documents/{document_id}/move")
async def move_document(
document_id: int,
request: DocumentMove,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Move a document to a folder (or root). Requires DOCUMENTS_UPDATE permission."""
try:
result = await session.execute(
select(Document).filter(Document.id == document_id)
)
document = result.scalars().first()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
session,
user,
document.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this search space",
)
if request.folder_id is not None:
target = await session.get(Folder, request.folder_id)
if not target:
raise HTTPException(status_code=404, detail="Target folder not found")
if target.search_space_id != document.search_space_id:
raise HTTPException(
status_code=400,
detail="Cannot move document to a folder in a different search space",
)
document.folder_id = request.folder_id
await session.commit()
return {"message": "Document moved successfully"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to move document: {e!s}"
) from e
@router.put("/documents/bulk-move")
async def bulk_move_documents(
request: BulkDocumentMove,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Move multiple documents to a folder (or root). Requires DOCUMENTS_UPDATE permission."""
try:
if not request.document_ids:
raise HTTPException(status_code=400, detail="No document IDs provided")
result = await session.execute(
select(Document).filter(Document.id.in_(request.document_ids))
)
documents = result.scalars().all()
if not documents:
raise HTTPException(status_code=404, detail="No documents found")
search_space_ids = {doc.search_space_id for doc in documents}
for ss_id in search_space_ids:
await check_permission(
session,
user,
ss_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this search space",
)
if request.folder_id is not None:
target = await session.get(Folder, request.folder_id)
if not target:
raise HTTPException(status_code=404, detail="Target folder not found")
mismatched = [
doc.id
for doc in documents
if doc.search_space_id != target.search_space_id
]
if mismatched:
raise HTTPException(
status_code=400,
detail="Cannot move documents to a folder in a different search space",
)
await session.execute(
Document.__table__.update()
.where(Document.id.in_(request.document_ids))
.values(folder_id=request.folder_id)
)
await session.commit()
return {"message": f"{len(request.document_ids)} documents moved successfully"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to move documents: {e!s}"
) from e

View file

@ -22,6 +22,16 @@ from .documents import (
ExtensionDocumentMetadata,
PaginatedResponse,
)
from .folders import (
BulkDocumentMove,
DocumentMove,
FolderBreadcrumb,
FolderCreate,
FolderMove,
FolderRead,
FolderReorder,
FolderUpdate,
)
from .google_drive import DriveItem, GoogleDriveIndexingOptions, GoogleDriveIndexRequest
from .image_generation import (
GlobalImageGenConfigRead,
@ -109,6 +119,8 @@ from .video_presentations import (
)
__all__ = [
# Folder schemas
"BulkDocumentMove",
# Chat schemas (assistant-ui integration)
"ChatMessage",
# Chunk schemas
@ -119,6 +131,7 @@ __all__ = [
"DefaultSystemInstructionsResponse",
# Document schemas
"DocumentBase",
"DocumentMove",
"DocumentRead",
"DocumentStatusBatchResponse",
"DocumentStatusItemRead",
@ -132,6 +145,12 @@ __all__ = [
"DriveItem",
"ExtensionDocumentContent",
"ExtensionDocumentMetadata",
"FolderBreadcrumb",
"FolderCreate",
"FolderMove",
"FolderRead",
"FolderReorder",
"FolderUpdate",
"GlobalImageGenConfigRead",
"GlobalNewLLMConfigRead",
"GoogleDriveIndexRequest",

View file

@ -59,6 +59,7 @@ class DocumentRead(BaseModel):
created_at: datetime
updated_at: datetime | None
search_space_id: int
folder_id: int | None = None
created_by_id: UUID | None = None # User who created/uploaded this document
created_by_name: str | None = None
created_by_email: str | None = None
@ -89,6 +90,7 @@ class DocumentTitleRead(BaseModel):
id: int
title: str
document_type: DocumentType
folder_id: int | None = None
model_config = ConfigDict(from_attributes=True)

View file

@ -0,0 +1,52 @@
"""Pydantic schemas for folder CRUD, move, and reorder operations."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class FolderCreate(BaseModel):
name: str = Field(max_length=255, min_length=1)
parent_id: int | None = None
search_space_id: int
class FolderUpdate(BaseModel):
name: str = Field(max_length=255, min_length=1)
class FolderMove(BaseModel):
new_parent_id: int | None = None
class FolderReorder(BaseModel):
before_position: str | None = None
after_position: str | None = None
class FolderRead(BaseModel):
id: int
name: str
position: str
parent_id: int | None
search_space_id: int
created_by_id: UUID | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class FolderBreadcrumb(BaseModel):
id: int
name: str
class DocumentMove(BaseModel):
folder_id: int | None = None
class BulkDocumentMove(BaseModel):
document_ids: list[int]
folder_id: int | None = None

View file

@ -0,0 +1,158 @@
"""Folder service: depth validation, circular reference checks, and position generation."""
from fastapi import HTTPException
from fractional_indexing import generate_key_between
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.db import Folder
MAX_FOLDER_DEPTH = 8
async def get_folder_depth(session: AsyncSession, folder_id: int) -> int:
"""Return the depth of a folder (root-level = 1) using a recursive CTE."""
result = await session.execute(
text("""
WITH RECURSIVE ancestors AS (
SELECT id, parent_id, 1 AS depth
FROM folders
WHERE id = :folder_id
UNION ALL
SELECT f.id, f.parent_id, a.depth + 1
FROM folders f
JOIN ancestors a ON f.id = a.parent_id
)
SELECT MAX(depth) FROM ancestors;
"""),
{"folder_id": folder_id},
)
return result.scalar() or 0
async def get_subtree_max_depth(session: AsyncSession, folder_id: int) -> int:
"""Return the maximum depth of any descendant below folder_id (0 if leaf)."""
result = await session.execute(
text("""
WITH RECURSIVE descendants AS (
SELECT id, 0 AS depth
FROM folders
WHERE parent_id = :folder_id
UNION ALL
SELECT f.id, d.depth + 1
FROM folders f
JOIN descendants d ON f.parent_id = d.id
)
SELECT COALESCE(MAX(depth), -1) FROM descendants;
"""),
{"folder_id": folder_id},
)
val = result.scalar()
return (val + 1) if val is not None and val >= 0 else 0
async def validate_folder_depth(
session: AsyncSession,
parent_id: int | None,
subtree_depth: int = 0,
) -> None:
"""Raise 400 if placing a folder (with subtree) under parent_id would exceed MAX_FOLDER_DEPTH."""
if parent_id is None:
parent_depth = 0
else:
parent_depth = await get_folder_depth(session, parent_id)
total = parent_depth + 1 + subtree_depth
if total > MAX_FOLDER_DEPTH:
raise HTTPException(
status_code=400,
detail=f"Maximum folder nesting depth is {MAX_FOLDER_DEPTH}. "
f"This operation would result in depth {total}.",
)
async def check_no_circular_reference(
session: AsyncSession,
folder_id: int,
new_parent_id: int | None,
) -> None:
"""Raise 400 if new_parent_id is folder_id itself or a descendant of folder_id."""
if new_parent_id is None:
return
if new_parent_id == folder_id:
raise HTTPException(
status_code=400,
detail="A folder cannot be moved into itself.",
)
result = await session.execute(
text("""
WITH RECURSIVE ancestors AS (
SELECT id, parent_id
FROM folders
WHERE id = :new_parent_id
UNION ALL
SELECT f.id, f.parent_id
FROM folders f
JOIN ancestors a ON f.id = a.parent_id
)
SELECT 1 FROM ancestors WHERE id = :folder_id LIMIT 1;
"""),
{"new_parent_id": new_parent_id, "folder_id": folder_id},
)
if result.scalar() is not None:
raise HTTPException(
status_code=400,
detail="Cannot move a folder into one of its own descendants.",
)
async def generate_folder_position(
session: AsyncSession,
search_space_id: int,
parent_id: int | None,
before_position: str | None = None,
after_position: str | None = None,
) -> str:
"""Generate a fractional index key for ordering a folder among its siblings.
- Default (no before/after): append after last sibling
- Prepend: before_position=None, after_position=first sibling position
- Insert between: both positions provided
"""
if before_position is not None or after_position is not None:
return generate_key_between(before_position, after_position)
# Append after last sibling
query = (
select(Folder.position)
.where(
Folder.search_space_id == search_space_id,
Folder.parent_id == parent_id
if parent_id is not None
else Folder.parent_id.is_(None),
)
.order_by(Folder.position.desc())
.limit(1)
)
result = await session.execute(query)
last_position = result.scalar()
return generate_key_between(last_position, None)
async def get_folder_subtree_ids(session: AsyncSession, folder_id: int) -> list[int]:
"""Return all folder IDs in the subtree rooted at folder_id (inclusive)."""
result = await session.execute(
text("""
WITH RECURSIVE subtree AS (
SELECT id FROM folders WHERE id = :folder_id
UNION ALL
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id = s.id
)
SELECT id FROM subtree;
"""),
{"folder_id": folder_id},
)
return list(result.scalars().all())

View file

@ -133,6 +133,51 @@ async def _delete_document_background(document_id: int) -> None:
await session.commit()
@celery_app.task(
name="delete_folder_documents_background",
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_backoff_max=300,
max_retries=5,
)
def delete_folder_documents_task(self, document_ids: list[int]):
"""Celery task to batch-delete documents orphaned by folder deletion."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(_delete_folder_documents(document_ids))
finally:
loop.close()
async def _delete_folder_documents(document_ids: list[int]) -> None:
"""Delete chunks in batches, then document rows for each orphaned document."""
from sqlalchemy import delete as sa_delete, select
from app.db import Chunk, Document
async with get_celery_session_maker()() as session:
batch_size = 500
for doc_id in document_ids:
while True:
chunk_ids_result = await session.execute(
select(Chunk.id)
.where(Chunk.document_id == doc_id)
.limit(batch_size)
)
chunk_ids = chunk_ids_result.scalars().all()
if not chunk_ids:
break
await session.execute(sa_delete(Chunk).where(Chunk.id.in_(chunk_ids)))
await session.commit()
doc = await session.get(Document, doc_id)
if doc:
await session.delete(doc)
await session.commit()
@celery_app.task(
name="delete_search_space_background",
bind=True,

View file

@ -73,6 +73,7 @@ dependencies = [
"langchain-daytona>=0.0.2",
"pypandoc>=1.16.2",
"notion-markdown>=0.7.0",
"fractional-indexing>=0.1.3",
]
[dependency-groups]

8570
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
"use client";
import { ListFilter, Search, Upload, X } from "lucide-react";
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
@ -17,12 +18,14 @@ export function DocumentsFilters({
searchValue,
onToggleType,
activeTypes,
onCreateFolder,
}: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
onSearch: (v: string) => void;
searchValue: string;
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[];
onCreateFolder?: () => void;
}) {
const t = useTranslations("documents");
const id = React.useId();
@ -194,6 +197,23 @@ export function DocumentsFilters({
)}
</div>
{/* New Folder Button */}
{onCreateFolder && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0 border-dashed border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>New folder</TooltipContent>
</Tooltip>
)}
{/* Upload Button */}
<Button
data-joyride="upload-button"

View file

@ -24,8 +24,7 @@ import {
} from "@/components/ui/dropdown-menu";
import type { Document } from "./types";
// Only FILE and NOTE document types can be edited
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
const EDITABLE_DOCUMENT_TYPES = ["NOTE"] as const;
// SURFSENSE_DOCS are system-managed and cannot be deleted
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
@ -47,20 +46,14 @@ export function RowActions({
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
);
// Documents in "pending" or "processing" state should show disabled delete
const isBeingProcessed =
document.status?.state === "pending" || document.status?.state === "processing";
// FILE documents that failed processing cannot be edited
const isFileFailed = document.document_type === "FILE" && document.status?.state === "failed";
// SURFSENSE_DOCS are system-managed and should not show delete at all
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
);
// Edit is disabled while processing OR for failed FILE documents
const isEditDisabled = isBeingProcessed || isFileFailed;
const isEditDisabled = isBeingProcessed;
const isDeleteDisabled = isBeingProcessed;
const handleDelete = async () => {

View file

@ -32,6 +32,7 @@ import {
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread";
@ -189,6 +190,7 @@ export default function NewChatPage() {
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -727,12 +729,10 @@ export default function NewChatPage() {
}
case "data-thread-title-update": {
// Handle thread title update from LLM-generated title
const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) {
// Update current thread state with new title
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
// Invalidate thread list to refresh sidebar
updateChatTabTitle({ chatId: currentThreadId, title: titleData.title });
queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId)],
});

View file

@ -1,3 +1,5 @@
"use client";
import type { Separator } from "fumadocs-core/page-tree";
export function SidebarSeparator({ item }: { item: Separator }) {

View file

@ -0,0 +1,19 @@
"use client";
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
/**
* Set of folder IDs that are currently expanded in the tree, keyed by search space ID.
* Persisted to localStorage so expand/collapse state survives page refreshes.
*/
export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
"surfsense:expandedFolderIds",
{},
);
/**
* Folder currently being renamed (inline edit mode).
* null means no folder is being renamed.
*/
export const renamingFolderIdAtom = atom<number | null>(null);

View file

@ -0,0 +1,217 @@
import { atom } from "jotai";
export type TabType = "chat" | "document";
export interface Tab {
id: string;
type: TabType;
title: string;
/** For chat tabs */
chatId?: number | null;
chatUrl?: string;
/** For document tabs */
documentId?: number;
searchSpaceId?: number;
}
interface TabsState {
tabs: Tab[];
activeTabId: string | null;
}
const INITIAL_CHAT_TAB: Tab = {
id: "chat-new",
type: "chat",
title: "New Chat",
chatId: null,
chatUrl: undefined,
};
const initialState: TabsState = {
tabs: [INITIAL_CHAT_TAB],
activeTabId: "chat-new",
};
export const tabsStateAtom = atom<TabsState>(initialState);
export const tabsAtom = atom((get) => get(tabsStateAtom).tabs);
export const activeTabIdAtom = atom((get) => get(tabsStateAtom).activeTabId);
export const activeTabAtom = atom((get) => {
const state = get(tabsStateAtom);
return state.tabs.find((t) => t.id === state.activeTabId) ?? null;
});
function makeChatTabId(chatId: number | null): string {
return chatId ? `chat-${chatId}` : "chat-new";
}
function makeDocumentTabId(documentId: number): string {
return `doc-${documentId}`;
}
/**
* Sync the current chat from Next.js routing into the tab bar.
* If a tab for this chat already exists, activate it.
* Otherwise, replace the "new chat" tab or create one.
*/
export const syncChatTabAtom = atom(
null,
(
get,
set,
{
chatId,
title,
chatUrl,
}: { chatId: number | null; title?: string; chatUrl?: string }
) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
const existing = state.tabs.find((t) => t.id === tabId);
if (existing) {
set(tabsStateAtom, {
...state,
activeTabId: tabId,
tabs: state.tabs.map((t) =>
t.id === tabId
? { ...t, title: title || t.title, chatUrl: chatUrl || t.chatUrl }
: t
),
});
return;
}
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
if (!chatId) {
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
if (hasNewChatTab) {
set(tabsStateAtom, { ...state, activeTabId: "chat-new" });
} else {
set(tabsStateAtom, {
tabs: [...state.tabs, INITIAL_CHAT_TAB],
activeTabId: "chat-new",
});
}
return;
}
// Replace the "new chat" tab if it exists and is empty, otherwise add new tab
const newChatTabIdx = state.tabs.findIndex((t) => t.id === "chat-new");
const newTab: Tab = {
id: tabId,
type: "chat",
title: title || `Chat ${chatId}`,
chatId,
chatUrl,
};
let updatedTabs: Tab[];
if (newChatTabIdx !== -1) {
updatedTabs = [...state.tabs];
updatedTabs[newChatTabIdx] = newTab;
} else {
updatedTabs = [...state.tabs, newTab];
}
set(tabsStateAtom, { tabs: updatedTabs, activeTabId: tabId });
}
);
/** Update the title of the current chat tab (e.g., when a chat gets its first response). */
export const updateChatTabTitleAtom = atom(
null,
(get, set, { chatId, title }: { chatId: number; title: string }) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
set(tabsStateAtom, {
...state,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
});
}
);
/** Open a document tab. If already open, just switch to it. */
export const openDocumentTabAtom = atom(
null,
(
get,
set,
{
documentId,
searchSpaceId,
title,
}: { documentId: number; searchSpaceId: number; title?: string }
) => {
const state = get(tabsStateAtom);
const tabId = makeDocumentTabId(documentId);
const existing = state.tabs.find((t) => t.id === tabId);
if (existing) {
set(tabsStateAtom, {
...state,
activeTabId: tabId,
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, title: title || t.title } : t
),
});
return;
}
const newTab: Tab = {
id: tabId,
type: "document",
title: title || `Document ${documentId}`,
documentId,
searchSpaceId,
};
set(tabsStateAtom, {
tabs: [...state.tabs, newTab],
activeTabId: tabId,
});
}
);
/** Switch to a tab by ID. Returns the tab so the caller can navigate if needed. */
export const switchTabAtom = atom(null, (get, set, tabId: string) => {
const state = get(tabsStateAtom);
const tab = state.tabs.find((t) => t.id === tabId);
if (tab) {
set(tabsStateAtom, { ...state, activeTabId: tabId });
}
return tab ?? null;
});
/** Close a tab. If it was active, activate the nearest sibling. */
export const closeTabAtom = atom(null, (get, set, tabId: string) => {
const state = get(tabsStateAtom);
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return null;
const remaining = state.tabs.filter((t) => t.id !== tabId);
// Don't close the last tab — always keep at least one
if (remaining.length === 0) {
set(tabsStateAtom, {
tabs: [INITIAL_CHAT_TAB],
activeTabId: "chat-new",
});
return INITIAL_CHAT_TAB;
}
let newActiveId = state.activeTabId;
if (state.activeTabId === tabId) {
// Activate the tab to the left (or right if first)
const newIdx = Math.min(idx, remaining.length - 1);
newActiveId = remaining[newIdx].id;
}
set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
return remaining.find((t) => t.id === newActiveId) ?? null;
});
/** Reset tabs when switching search spaces. */
export const resetTabsAtom = atom(null, (_get, set) => {
set(tabsStateAtom, { ...initialState });
});

View file

@ -0,0 +1,94 @@
"use client";
import { FolderPlus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface CreateFolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
parentFolderName?: string | null;
onConfirm: (name: string) => void;
}
export function CreateFolderDialog({
open,
onOpenChange,
parentFolderName,
onConfirm,
}: CreateFolderDialogProps) {
const [name, setName] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setName("");
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
e?.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
onConfirm(trimmed);
onOpenChange(false);
},
[name, onConfirm, onOpenChange],
);
const isSubfolder = !!parentFolderName;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderPlus className="size-5 text-muted-foreground" />
{isSubfolder ? "New subfolder" : "New folder"}
</DialogTitle>
<DialogDescription>
{isSubfolder
? `Create a new folder inside "${parentFolderName}".`
: "Create a new folder at the root level."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="folder-name">Folder name</Label>
<Input
ref={inputRef}
id="folder-name"
placeholder="e.g. Research, Notes, Archive…"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={255}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,192 @@
"use client";
import {
Eye,
MoreHorizontal,
Move,
Pencil,
Trash2,
} from "lucide-react";
import React, { useCallback, useRef } from "react";
import { useDrag } from "react-dnd";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode";
export interface DocumentNodeDoc {
id: number;
title: string;
document_type: string;
folderId: number | null;
status?: { state: string; reason?: string | null };
}
interface DocumentNodeProps {
doc: DocumentNodeDoc;
depth: number;
isMentioned: boolean;
onToggleChatMention: (doc: DocumentNodeDoc, isMentioned: boolean) => void;
onPreview: (doc: DocumentNodeDoc) => void;
onEdit: (doc: DocumentNodeDoc) => void;
onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void;
}
export const DocumentNode = React.memo(function DocumentNode({
doc,
depth,
isMentioned,
onToggleChatMention,
onPreview,
onEdit,
onDelete,
onMove,
}: DocumentNodeProps) {
const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing";
const isEditable =
doc.document_type === "NOTE" &&
statusState !== "pending" &&
statusState !== "processing";
const handleCheckChange = useCallback(() => {
if (isSelectable) {
onToggleChatMention(doc, isMentioned);
}
}, [doc, isMentioned, isSelectable, onToggleChatMention]);
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_TYPES.DOCUMENT,
item: { id: doc.id },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}),
[doc.id],
);
const isProcessing = statusState === "pending" || statusState === "processing";
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={drag}
className={cn(
"group flex h-8 items-center gap-1.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
isMentioned && "bg-accent/30",
isDragging && "opacity-40",
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={handleCheckChange}
>
{isSelectable ? (
<Checkbox
checked={isMentioned}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
) : (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<span
className={cn(
"h-2 w-2 rounded-full",
statusState === "processing" && "animate-pulse bg-amber-500",
statusState === "pending" && "bg-muted-foreground/40",
statusState === "failed" && "bg-destructive",
)}
/>
</span>
)}
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
<span className="shrink-0">
{getDocumentTypeIcon(doc.document_type as DocumentTypeEnum, "h-3.5 w-3.5 text-muted-foreground")}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Preview
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
onClick={() => onDelete(doc)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Preview
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
onClick={() => onDelete(doc)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
});

View file

@ -0,0 +1,333 @@
"use client";
import {
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
FolderPlus,
MoreHorizontal,
Move,
Pencil,
Trash2,
} from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useDrag, useDrop } from "react-dnd";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export const DND_TYPES = {
FOLDER: "FOLDER",
DOCUMENT: "DOCUMENT",
} as const;
type DropZone = "top" | "middle" | "bottom";
export interface FolderDisplay {
id: number;
name: string;
position: string;
parentId: number | null;
searchSpaceId: number;
}
interface FolderNodeProps {
folder: FolderDisplay;
depth: number;
isExpanded: boolean;
isRenaming: boolean;
childCount: number;
onToggleExpand: (folderId: number) => void;
onRename: (folder: FolderDisplay, newName: string) => void;
onStartRename: (folderId: number) => void;
onCancelRename: () => void;
onDelete: (folder: FolderDisplay) => void;
onMove: (folder: FolderDisplay) => void;
onCreateSubfolder: (parentId: number) => void;
onDropIntoFolder?: (itemType: "folder" | "document", itemId: number, targetFolderId: number) => void;
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
siblingPositions?: { before: string | null; after: string | null };
disabledDropIds?: Set<number>;
}
function getDropZone(monitor: { getClientOffset: () => { y: number } | null }, element: HTMLElement): DropZone {
const offset = monitor.getClientOffset();
if (!offset) return "middle";
const rect = element.getBoundingClientRect();
const y = offset.y - rect.top;
const pct = y / rect.height;
if (pct < 0.25) return "top";
if (pct > 0.75) return "bottom";
return "middle";
}
export const FolderNode = React.memo(function FolderNode({
folder,
depth,
isExpanded,
isRenaming,
childCount,
onToggleExpand,
onRename,
onStartRename,
onCancelRename,
onDelete,
onMove,
onCreateSubfolder,
onDropIntoFolder,
onReorderFolder,
siblingPositions,
disabledDropIds,
}: FolderNodeProps) {
const [renameValue, setRenameValue] = useState(folder.name);
const inputRef = useRef<HTMLInputElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const [dropZone, setDropZone] = useState<DropZone | null>(null);
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_TYPES.FOLDER,
item: { id: folder.id, position: folder.position, parentId: folder.parentId },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}),
[folder.id, folder.position, folder.parentId],
);
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: [DND_TYPES.FOLDER, DND_TYPES.DOCUMENT],
canDrop: (item: { id: number }) => {
if (item.id === folder.id) return false;
if (disabledDropIds?.has(item.id)) return false;
return true;
},
hover: (_item, monitor) => {
if (!rowRef.current || !monitor.isOver({ shallow: true })) {
setDropZone(null);
return;
}
setDropZone(getDropZone(monitor, rowRef.current));
},
drop: (item: { id: number }, monitor) => {
if (!rowRef.current) return;
const zone = getDropZone(monitor, rowRef.current);
const type = monitor.getItemType();
if (zone === "middle") {
if (type === DND_TYPES.FOLDER) {
onDropIntoFolder?.("folder", item.id, folder.id);
} else {
onDropIntoFolder?.("document", item.id, folder.id);
}
} else if (type === DND_TYPES.FOLDER && onReorderFolder && siblingPositions) {
if (zone === "top") {
onReorderFolder(item.id, siblingPositions.before, folder.position);
} else {
onReorderFolder(item.id, folder.position, siblingPositions.after);
}
}
setDropZone(null);
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
}),
[folder.id, folder.position, disabledDropIds, onDropIntoFolder, onReorderFolder, siblingPositions],
);
useEffect(() => {
if (!isOver) setDropZone(null);
}, [isOver]);
const attachRef = useCallback(
(node: HTMLDivElement | null) => {
rowRef.current = node;
drag(drop(node));
},
[drag, drop],
);
useEffect(() => {
if (isRenaming && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isRenaming]);
const handleRenameSubmit = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== folder.name) {
onRename(folder, trimmed);
}
onCancelRename();
}, [renameValue, folder, onRename, onCancelRename]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
setRenameValue(folder.name);
onCancelRename();
}
},
[handleRenameSubmit, folder.name, onCancelRename],
);
const startRename = useCallback(() => {
setRenameValue(folder.name);
onStartRename(folder.id);
}, [folder, onStartRename]);
const FolderIcon = isExpanded ? FolderOpen : Folder;
return (
<ContextMenu>
<ContextMenuTrigger asChild disabled={isRenaming}>
<div
ref={attachRef}
className={cn(
"group relative flex h-8 items-center gap-1 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
isExpanded && "font-medium",
isDragging && "opacity-40",
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",
isOver && canDrop && dropZone === "top" && "border-t-2 border-primary",
isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary",
isOver && !canDrop && "cursor-not-allowed",
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={() => onToggleExpand(folder.id)}
onDoubleClick={(e) => {
e.stopPropagation();
startRename();
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</span>
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
{isRenaming ? (
<input
ref={inputRef}
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleRenameSubmit}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
className="flex-1 min-w-0 rounded border border-primary bg-background px-1 py-0.5 text-sm outline-none"
/>
) : (
<span className="flex-1 min-w-0 truncate">{folder.name}</span>
)}
{!isRenaming && childCount > 0 && (
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{childCount}
</span>
)}
{!isRenaming && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onCreateSubfolder(folder.id);
}}
>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
startRename();
}}
>
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onMove(folder);
}}
>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(folder);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</ContextMenuTrigger>
{!isRenaming && (
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => onDelete(folder)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
});

View file

@ -0,0 +1,157 @@
"use client";
import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { FolderDisplay } from "./FolderNode";
interface FolderPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
folders: FolderDisplay[];
title: string;
description?: string;
disabledFolderIds?: Set<number>;
onSelect: (folderId: number | null) => void;
}
export function FolderPickerDialog({
open,
onOpenChange,
folders,
title,
description,
disabledFolderIds,
onSelect,
}: FolderPickerDialogProps) {
const [selectedId, setSelectedId] = useState<number | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
useEffect(() => {
if (open) {
setSelectedId(null);
setExpandedIds(new Set());
}
}, [open]);
const foldersByParent = useMemo(() => {
const map: Record<string, FolderDisplay[]> = {};
for (const f of folders) {
const key = f.parentId ?? "root";
(map[key] ??= []).push(f);
}
return map;
}, [folders]);
const toggleExpand = useCallback((id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleConfirm = useCallback(() => {
onSelect(selectedId);
onOpenChange(false);
}, [selectedId, onSelect, onOpenChange]);
function renderPickerLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root";
const children = (foldersByParent[key] ?? [])
.slice()
.sort((a, b) => a.position.localeCompare(b.position));
return children.flatMap((f) => {
const isDisabled = disabledFolderIds?.has(f.id) ?? false;
const isExpanded = expandedIds.has(f.id);
const hasChildren = (foldersByParent[f.id] ?? []).length > 0;
const isSelected = selectedId === f.id;
const FolderIcon = isExpanded ? FolderOpen : Folder;
return [
<button
key={f.id}
type="button"
disabled={isDisabled}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
isSelected && "bg-accent text-accent-foreground",
!isSelected && !isDisabled && "hover:bg-accent/50",
isDisabled && "cursor-not-allowed opacity-40",
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
if (!isDisabled) setSelectedId(f.id);
}}
>
{hasChildren ? (
<span
className="flex h-4 w-4 shrink-0 items-center justify-center"
onClick={(e) => {
e.stopPropagation();
toggleExpand(f.id);
}}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
) : (
<span className="h-4 w-4 shrink-0" />
)}
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{f.name}</span>
</button>,
...(isExpanded ? renderPickerLevel(f.id, depth + 1) : []),
];
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="max-h-[300px] overflow-y-auto rounded-md border p-1">
<button
type="button"
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
selectedId === null && "bg-accent text-accent-foreground",
selectedId !== null && "hover:bg-accent/50",
)}
onClick={() => setSelectedId(null)}
>
<span className="h-4 w-4 shrink-0" />
<Home className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Root</span>
</button>
{renderPickerLevel(null, 1)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm}>Move here</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,216 @@
"use client";
import { useAtom } from "jotai";
import { TreePine } from "lucide-react";
import { useCallback, useMemo } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
import { FolderNode, type FolderDisplay } from "./FolderNode";
interface FolderTreeViewProps {
folders: FolderDisplay[];
documents: DocumentNodeDoc[];
expandedIds: Set<number>;
onToggleExpand: (folderId: number) => void;
mentionedDocIds: Set<number>;
onToggleChatMention: (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => void;
onRenameFolder: (folder: FolderDisplay, newName: string) => void;
onDeleteFolder: (folder: FolderDisplay) => void;
onMoveFolder: (folder: FolderDisplay) => void;
onCreateFolder: (parentId: number | null) => void;
onPreviewDocument: (doc: DocumentNodeDoc) => void;
onEditDocument: (doc: DocumentNodeDoc) => void;
onDeleteDocument: (doc: DocumentNodeDoc) => void;
onMoveDocument: (doc: DocumentNodeDoc) => void;
activeTypes: DocumentTypeEnum[];
onDropIntoFolder?: (itemType: "folder" | "document", itemId: number, targetFolderId: number | null) => void;
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
}
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
const result: Record<string | number, T[]> = {};
for (const item of items) {
const key = keyFn(item);
(result[key] ??= []).push(item);
}
return result;
}
export function FolderTreeView({
folders,
documents,
expandedIds,
onToggleExpand,
mentionedDocIds,
onToggleChatMention,
onRenameFolder,
onDeleteFolder,
onMoveFolder,
onCreateFolder,
onPreviewDocument,
onEditDocument,
onDeleteDocument,
onMoveDocument,
activeTypes,
onDropIntoFolder,
onReorderFolder,
}: FolderTreeViewProps) {
const foldersByParent = useMemo(
() => groupBy(folders, (f) => f.parentId ?? "root"),
[folders],
);
const docsByFolder = useMemo(
() => groupBy(documents, (d) => d.folderId ?? "root"),
[documents],
);
const folderChildCounts = useMemo(() => {
const counts: Record<number, number> = {};
for (const f of folders) {
const children = foldersByParent[f.id] ?? [];
const docs = docsByFolder[f.id] ?? [];
counts[f.id] = children.length + docs.length;
}
return counts;
}, [folders, foldersByParent, docsByFolder]);
// Single subscription for rename state — derived boolean passed to each FolderNode
const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom);
const handleStartRename = useCallback(
(folderId: number) => setRenamingFolderId(folderId),
[setRenamingFolderId],
);
const handleCancelRename = useCallback(
() => setRenamingFolderId(null),
[setRenamingFolderId],
);
const hasDescendantMatch = useMemo(() => {
if (activeTypes.length === 0) return null;
const match: Record<number, boolean> = {};
function check(folderId: number): boolean {
if (match[folderId] !== undefined) return match[folderId];
const childDocs = (docsByFolder[folderId] ?? []).some((d) =>
activeTypes.includes(d.document_type as DocumentTypeEnum),
);
if (childDocs) {
match[folderId] = true;
return true;
}
const childFolders = foldersByParent[folderId] ?? [];
for (const cf of childFolders) {
if (check(cf.id)) {
match[folderId] = true;
return true;
}
}
match[folderId] = false;
return false;
}
for (const f of folders) {
check(f.id);
}
return match;
}, [folders, docsByFolder, foldersByParent, activeTypes]);
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root";
const childFolders = (foldersByParent[key] ?? [])
.slice()
.sort((a, b) => a.position.localeCompare(b.position));
const visibleFolders = hasDescendantMatch
? childFolders.filter((f) => hasDescendantMatch[f.id])
: childFolders;
const childDocs = (docsByFolder[key] ?? [])
.filter(
(d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum),
);
const nodes: React.ReactNode[] = [];
for (let i = 0; i < visibleFolders.length; i++) {
const f = visibleFolders[i];
const siblingPositions = {
before: i > 0 ? visibleFolders[i - 1].position : null,
after: i < visibleFolders.length - 1 ? visibleFolders[i + 1].position : null,
};
nodes.push(
<FolderNode
key={`folder-${f.id}`}
folder={f}
depth={depth}
isExpanded={expandedIds.has(f.id)}
isRenaming={renamingFolderId === f.id}
childCount={folderChildCounts[f.id] ?? 0}
onToggleExpand={onToggleExpand}
onRename={onRenameFolder}
onStartRename={handleStartRename}
onCancelRename={handleCancelRename}
onDelete={onDeleteFolder}
onMove={onMoveFolder}
onCreateSubfolder={onCreateFolder}
onDropIntoFolder={onDropIntoFolder}
onReorderFolder={onReorderFolder}
siblingPositions={siblingPositions}
/>,
);
if (expandedIds.has(f.id)) {
nodes.push(...renderLevel(f.id, depth + 1));
}
}
for (const d of childDocs) {
nodes.push(
<DocumentNode
key={`doc-${d.id}`}
doc={d}
depth={depth}
isMentioned={mentionedDocIds.has(d.id)}
onToggleChatMention={onToggleChatMention}
onPreview={onPreviewDocument}
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
/>,
);
}
return nodes;
}
const treeNodes = renderLevel(null, 0);
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<TreePine className="h-10 w-10" />
<p className="text-sm">No documents yet</p>
</div>
);
}
if (treeNodes.length === 0 && activeTypes.length > 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<TreePine className="h-10 w-10" />
<p className="text-sm">No matching documents</p>
</div>
);
}
return (
<DndProvider backend={HTML5Backend}>
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">
{treeNodes}
</div>
</DndProvider>
);
}

View file

@ -14,6 +14,7 @@ import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import {
morePagesDialogAtom,
searchSpaceSettingsDialogAtom,
@ -100,6 +101,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
const syncChatTab = useSetAtom(syncChatTabAtom);
const resetTabs = useSetAtom(resetTabsAtom);
// State for handling new chat navigation when router is out of sync
const [pendingNewChat, setPendingNewChat] = useState(false);
@ -264,10 +267,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}
}, [pendingNewChat, params?.chat_id, router, searchSpaceId, resetCurrentThread]);
// Reset transient slide-out panels when switching search spaces.
// Reset transient slide-out panels and tabs when switching search spaces.
useEffect(() => {
setActiveSlideoutPanel(null);
}, [searchSpaceId]);
resetTabs();
}, [searchSpaceId, resetTabs]);
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
@ -307,6 +311,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
router,
]);
// Sync current chat route with tab state
useEffect(() => {
const chatId = currentChatId ?? null;
const chatUrl = chatId
? `/dashboard/${searchSpaceId}/new-chat/${chatId}`
: `/dashboard/${searchSpaceId}/new-chat`;
const thread = threadsData?.threads?.find((t) => t.id === chatId);
syncChatTab({
chatId,
title: thread?.title || (chatId ? `Chat ${chatId}` : "New Chat"),
chatUrl,
});
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
// Transform and split chats into private and shared based on visibility
const { myChats, sharedChats } = useMemo(() => {
if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
@ -473,6 +491,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, router, t]);
const handleTabSwitch = useCallback(
(tab: Tab) => {
if (tab.type === "chat") {
const url = tab.chatUrl || `/dashboard/${searchSpaceId}/new-chat`;
router.push(url);
}
// Document tabs are handled in-place by LayoutShell — no navigation needed
},
[router, searchSpaceId]
);
const handleNavItemClick = useCallback(
(item: NavItem) => {
if (item.url === "#inbox") {
@ -738,6 +767,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
isDocked: isDocumentsDocked,
onDockedChange: setIsDocumentsDocked,
}}
onTabSwitch={handleTabSwitch}
>
<Fragment key={chatResetKey}>{children}</Fragment>
</LayoutShell>

View file

@ -8,6 +8,7 @@ import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { Button } from "@/components/ui/button";
@ -23,12 +24,14 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const pathname = usePathname();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const isMobile = useIsMobile();
const activeTab = useAtomValue(activeTabAtom);
const isChatPage = pathname?.includes("/new-chat") ?? false;
const isDocumentTab = activeTab?.type === "document";
const currentThreadState = useAtomValue(currentThreadAtom);
const hasThread = isChatPage && currentThreadState.id !== null;
const hasThread = isChatPage && !isDocumentTab && currentThreadState.id !== null;
const threadForButton: ThreadRecord | null =
hasThread && currentThreadState.id !== null
@ -58,7 +61,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
{/* Left side - Mobile menu trigger + Model selector */}
<div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger}
{isChatPage && searchSpaceId && (
{isChatPage && !isDocumentTab && searchSpaceId && (
<ChatHeader searchSpaceId={Number(searchSpaceId)} className="md:h-9 md:px-4 md:text-sm" />
)}
</div>

View file

@ -1,7 +1,9 @@
"use client";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
@ -23,6 +25,8 @@ import {
Sidebar,
} from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import { DocumentTabContent } from "../tabs/DocumentTabContent";
import { TabBar } from "../tabs/TabBar";
// Per-tab data source
interface TabDataSource {
@ -97,6 +101,44 @@ interface LayoutShellProps {
isDocked?: boolean;
onDockedChange?: (docked: boolean) => void;
};
onTabSwitch?: (tab: Tab) => void;
}
function MainContentPanel({
isChatPage,
onTabSwitch,
onNewChat,
children,
}: {
isChatPage: boolean;
onTabSwitch?: (tab: Tab) => void;
onNewChat?: () => void;
children: React.ReactNode;
}) {
const activeTab = useAtomValue(activeTabAtom);
const isDocumentTab = activeTab?.type === "document";
return (
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
<TabBar onTabSwitch={onTabSwitch} onNewChat={onNewChat} />
<Header />
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (
<div className="flex-1 overflow-hidden">
<DocumentTabContent
key={activeTab.documentId}
documentId={activeTab.documentId}
searchSpaceId={activeTab.searchSpaceId}
title={activeTab.title}
/>
</div>
) : (
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
)}
</div>
);
}
export function LayoutShell({
@ -138,6 +180,7 @@ export function LayoutShell({
allSharedChatsPanel,
allPrivateChatsPanel,
documentsPanel,
onTabSwitch,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -454,14 +497,14 @@ export function LayoutShell({
/>
)}
{/* Main content panel */}
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
<Header />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
</div>
{/* Main content panel */}
<MainContentPanel
isChatPage={isChatPage}
onTabSwitch={onTabSwitch}
onNewChat={onNewChat}
>
{children}
</MainContentPanel>
{/* Right panel — tabbed Sources/Report (desktop only) */}
{documentsPanel && (

View file

@ -1,6 +1,7 @@
"use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
import { ChevronLeft, ChevronRight, Unplug } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@ -15,15 +16,24 @@ import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.a
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useQuery } from "@rocicorp/zero/react";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
const SHOWCASE_CONNECTORS = [
@ -63,6 +73,7 @@ export function DocumentsSidebar({
const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const openDocumentTab = useSetAtom(openDocumentTabAtom);
const { data: connectors } = useAtomValue(connectorsAtom);
const connectorCount = connectors?.length ?? 0;
@ -76,6 +87,219 @@ export function DocumentsSidebar({
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
// Folder state
const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom);
const expandedIds = useMemo(
() => new Set(expandedFolderMap[searchSpaceId] ?? []),
[expandedFolderMap, searchSpaceId],
);
const toggleFolderExpand = useCallback(
(folderId: number) => {
setExpandedFolderMap((prev) => {
const current = new Set(prev[searchSpaceId] ?? []);
if (current.has(folderId)) current.delete(folderId);
else current.add(folderId);
return { ...prev, [searchSpaceId]: [...current] };
});
},
[searchSpaceId, setExpandedFolderMap],
);
// Zero queries for tree data
const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId }));
const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId }));
const treeFolders: FolderDisplay[] = useMemo(
() =>
(zeroFolders ?? []).map((f) => ({
id: f.id,
name: f.name,
position: f.position,
parentId: f.parentId ?? null,
searchSpaceId: f.searchSpaceId,
})),
[zeroFolders],
);
const treeDocuments: DocumentNodeDoc[] = useMemo(
() =>
(zeroAllDocs ?? [])
.filter((d) => d.title && d.title.trim() !== "")
.map((d) => ({
id: d.id,
title: d.title,
document_type: d.documentType,
folderId: (d as { folderId?: number | null }).folderId ?? null,
status: d.status as { state: string; reason?: string | null } | undefined,
})),
[zeroAllDocs],
);
const foldersByParent = useMemo(() => {
const map: Record<string, FolderDisplay[]> = {};
for (const f of treeFolders) {
const key = String(f.parentId ?? "root");
(map[key] ??= []).push(f);
}
return map;
}, [treeFolders]);
// Folder actions
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [folderPickerTarget, setFolderPickerTarget] = useState<{
type: "folder" | "document";
id: number;
disabledIds?: Set<number>;
} | null>(null);
// Create-folder dialog state
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [createFolderParentId, setCreateFolderParentId] = useState<number | null>(null);
const createFolderParentName = useMemo(() => {
if (createFolderParentId === null) return null;
return treeFolders.find((f) => f.id === createFolderParentId)?.name ?? null;
}, [createFolderParentId, treeFolders]);
const handleCreateFolder = useCallback(
(parentId: number | null) => {
setCreateFolderParentId(parentId);
setCreateFolderOpen(true);
},
[],
);
const handleCreateFolderConfirm = useCallback(
async (name: string) => {
try {
await foldersApiService.createFolder({
name,
parent_id: createFolderParentId,
search_space_id: searchSpaceId,
});
toast.success("Folder created");
if (createFolderParentId !== null) {
setExpandedFolderMap((prev) => {
const current = new Set(prev[searchSpaceId] ?? []);
current.add(createFolderParentId);
return { ...prev, [searchSpaceId]: [...current] };
});
}
} catch (e: any) {
toast.error(e?.message || "Failed to create folder");
}
},
[createFolderParentId, searchSpaceId, setExpandedFolderMap],
);
const handleRenameFolder = useCallback(
async (folder: FolderDisplay, newName: string) => {
try {
await foldersApiService.updateFolder(folder.id, { name: newName });
toast.success("Folder renamed");
} catch (e: any) {
toast.error(e?.message || "Failed to rename folder");
}
},
[],
);
const handleDeleteFolder = useCallback(
async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
await foldersApiService.deleteFolder(folder.id);
toast.success("Folder deleted");
} catch (e: any) {
toast.error(e?.message || "Failed to delete folder");
}
},
[],
);
const handleMoveFolder = useCallback(
(folder: FolderDisplay) => {
const subtreeIds = new Set<number>();
function collectSubtree(id: number) {
subtreeIds.add(id);
for (const child of foldersByParent[String(id)] ?? []) {
collectSubtree(child.id);
}
}
collectSubtree(folder.id);
setFolderPickerTarget({
type: "folder",
id: folder.id,
disabledIds: subtreeIds,
});
setFolderPickerOpen(true);
},
[foldersByParent],
);
const handleMoveDocument = useCallback((doc: DocumentNodeDoc) => {
setFolderPickerTarget({ type: "document", id: doc.id });
setFolderPickerOpen(true);
}, []);
const handleFolderPickerSelect = useCallback(
async (targetFolderId: number | null) => {
if (!folderPickerTarget) return;
try {
if (folderPickerTarget.type === "folder") {
await foldersApiService.moveFolder(folderPickerTarget.id, {
new_parent_id: targetFolderId,
});
toast.success("Folder moved");
} else {
await foldersApiService.moveDocument(folderPickerTarget.id, {
folder_id: targetFolderId,
});
toast.success("Document moved");
}
} catch (e: any) {
toast.error(e?.message || "Failed to move item");
}
setFolderPickerTarget(null);
},
[folderPickerTarget],
);
const handleDropIntoFolder = useCallback(
async (itemType: "folder" | "document", itemId: number, targetFolderId: number | null) => {
try {
if (itemType === "folder") {
await foldersApiService.moveFolder(itemId, {
new_parent_id: targetFolderId,
});
toast.success("Folder moved");
} else {
await foldersApiService.moveDocument(itemId, {
folder_id: targetFolderId,
});
toast.success("Document moved");
}
} catch (e: any) {
toast.error(e?.message || "Failed to move item");
}
},
[],
);
const handleReorderFolder = useCallback(
async (folderId: number, beforePos: string | null, afterPos: string | null) => {
try {
await foldersApiService.reorderFolder(folderId, {
before_position: beforePos,
after_position: afterPos,
});
} catch (e: any) {
toast.error(e?.message || "Failed to reorder folder");
}
},
[],
);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
@ -123,14 +347,14 @@ export function DocumentsSidebar({
const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore;
const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore;
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
const onToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => {
if (checked) {
return prev.includes(type) ? prev : [...prev, type];
}
return prev.filter((t) => t !== type);
});
};
}, []);
const handleDeleteDocument = useCallback(
async (id: number): Promise<boolean> => {
@ -340,27 +564,83 @@ export function DocumentsSidebar({
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)}
/>
</div>
<DocumentsTableShell
documents={displayDocs}
loading={!!loading}
error={!!error}
sortKey={sortKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
deleteDocument={handleDeleteDocument}
bulkDeleteDocuments={handleBulkDeleteDocuments}
searchSpaceId={String(searchSpaceId)}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
isSearchMode={isSearchMode || activeTypes.length > 0}
/>
{isSearchMode ? (
<DocumentsTableShell
documents={displayDocs}
loading={!!loading}
error={!!error}
sortKey={sortKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
deleteDocument={handleDeleteDocument}
bulkDeleteDocuments={handleBulkDeleteDocuments}
searchSpaceId={String(searchSpaceId)}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
isSearchMode={isSearchMode || activeTypes.length > 0}
/>
) : (
<FolderTreeView
folders={treeFolders}
documents={treeDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
onPreviewDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
/>
)}
</div>
<FolderPickerDialog
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
folders={treeFolders}
title={
folderPickerTarget?.type === "folder"
? "Move folder to..."
: "Move document to..."
}
description="Select a destination folder, or choose Root to move to the top level."
disabledFolderIds={folderPickerTarget?.disabledIds}
onSelect={handleFolderPickerSelect}
/>
<CreateFolderDialog
open={createFolderOpen}
onOpenChange={setCreateFolderOpen}
parentFolderName={createFolderParentName}
onConfirm={handleCreateFolderConfirm}
/>
</>
);

View file

@ -0,0 +1,237 @@
"use client";
import { AlertCircle, Pencil } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { PlateEditor } from "@/components/editor/plate-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface DocumentContent {
document_id: number;
title: string;
document_type?: string;
source_markdown: string;
}
function DocumentSkeleton() {
return (
<div className="space-y-6 p-8 max-w-4xl mx-auto">
<div className="h-8 w-3/4 rounded-md bg-muted/60 animate-pulse" />
<div className="space-y-3">
<div className="h-4 w-full rounded-md bg-muted/60 animate-pulse" />
<div className="h-4 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" />
<div className="h-4 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" />
<div className="h-4 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" />
</div>
<div className="h-6 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" />
<div className="space-y-3">
<div className="h-4 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" />
<div className="h-4 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" />
<div className="h-4 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" />
</div>
</div>
);
}
interface DocumentTabContentProps {
documentId: number;
searchSpaceId: number;
title?: string;
}
export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) {
const [doc, setDoc] = useState<DocumentContent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false);
const changeCountRef = useRef(0);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
setDoc(null);
setIsEditing(false);
setEditedMarkdown(null);
initialLoadDone.current = false;
changeCountRef.current = 0;
const fetchContent = async () => {
const token = getBearerToken();
if (!token) {
redirectToLogin();
return;
}
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
{ method: "GET" }
);
if (cancelled) return;
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
throw new Error(errorData.detail || "Failed to fetch document");
}
const data = await response.json();
if (data.source_markdown === undefined || data.source_markdown === null) {
setError("This document does not have viewable content.");
setIsLoading(false);
return;
}
markdownRef.current = data.source_markdown;
setDoc(data);
initialLoadDone.current = true;
} catch (err) {
if (cancelled) return;
console.error("Error fetching document:", err);
setError(err instanceof Error ? err.message : "Failed to fetch document");
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchContent();
return () => {
cancelled = true;
};
}, [documentId, searchSpaceId]);
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
if (!initialLoadDone.current) return;
changeCountRef.current += 1;
if (changeCountRef.current <= 1) return;
setEditedMarkdown(md);
}, []);
const handleSave = useCallback(async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
setSaving(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: markdownRef.current }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
setDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
} finally {
setSaving(false);
}
}, [documentId, searchSpaceId]);
if (isLoading) return <DocumentSkeleton />;
if (error || !doc) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="size-10 text-destructive" />
<div>
<p className="font-medium text-foreground text-lg">Failed to load document</p>
<p className="text-sm text-muted-foreground mt-1">
{error || "An unknown error occurred"}
</p>
</div>
</div>
);
}
if (isEditing) {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
<div className="flex-1 min-w-0">
<h1 className="text-base font-semibold truncate">
{doc.title || title || "Untitled"}
</h1>
{editedMarkdown !== null && (
<p className="text-xs text-muted-foreground">Unsaved changes</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(false);
setEditedMarkdown(null);
changeCountRef.current = 0;
}}
>
Done editing
</Button>
</div>
<div className="flex-1 overflow-hidden">
<PlateEditor
key={`edit-${documentId}`}
preset="full"
markdown={doc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={false}
placeholder="Start writing..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
defaultEditing={true}
/>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
<h1 className="text-base font-semibold truncate flex-1 min-w-0">
{doc.title || title || "Untitled"}
</h1>
{doc.document_type === "NOTE" && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)} className="gap-1.5">
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto px-6 py-6">
<MarkdownViewer content={doc.source_markdown} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,129 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { FileText, MessageSquare, Plus, X } from "lucide-react";
import { useCallback, useRef, useEffect } from "react";
import {
activeTabIdAtom,
closeTabAtom,
switchTabAtom,
tabsAtom,
type Tab,
} from "@/atoms/tabs/tabs.atom";
import { cn } from "@/lib/utils";
interface TabBarProps {
onTabSwitch?: (tab: Tab) => void;
onNewChat?: () => void;
className?: string;
}
export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
const tabs = useAtomValue(tabsAtom);
const activeTabId = useAtomValue(activeTabIdAtom);
const switchTab = useSetAtom(switchTabAtom);
const closeTab = useSetAtom(closeTabAtom);
const scrollRef = useRef<HTMLDivElement>(null);
const handleTabClick = useCallback(
(tab: Tab) => {
if (tab.id === activeTabId) return;
switchTab(tab.id);
onTabSwitch?.(tab);
},
[activeTabId, switchTab, onTabSwitch]
);
const handleTabClose = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
const fallback = closeTab(tabId);
if (fallback) {
onTabSwitch?.(fallback);
}
},
[closeTab, onTabSwitch]
);
// Scroll active tab into view
useEffect(() => {
if (!scrollRef.current || !activeTabId) return;
const activeEl = scrollRef.current.querySelector(`[data-tab-id="${activeTabId}"]`);
if (activeEl) {
activeEl.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
}
}, [activeTabId]);
// Only show tab bar when there's more than one tab
if (tabs.length <= 1) return null;
return (
<div
className={cn(
"flex items-center shrink-0 border-b bg-main-panel",
className
)}
>
<div
ref={scrollRef}
className="flex items-center flex-1 overflow-x-auto scrollbar-none"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const Icon = tab.type === "document" ? FileText : MessageSquare;
return (
<button
key={tab.id}
type="button"
data-tab-id={tab.id}
onClick={() => handleTabClick(tab)}
className={cn(
"group relative flex items-center gap-1.5 px-3 h-9 min-w-0 max-w-[200px] text-xs font-medium border-r transition-colors shrink-0",
isActive
? "bg-main-panel text-foreground"
: "bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)}
>
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary" />
)}
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{tab.title}</span>
<span
role="button"
tabIndex={0}
onClick={(e) => handleTabClose(e, tab.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTabClose(e as unknown as React.MouseEvent, tab.id);
}
}}
className={cn(
"ml-auto shrink-0 rounded-sm p-0.5 transition-colors",
isActive
? "opacity-60 hover:opacity-100 hover:bg-muted"
: "opacity-0 group-hover:opacity-60 hover:opacity-100! hover:bg-muted"
)}
>
<X className="size-3" />
</span>
</button>
);
})}
</div>
{onNewChat && (
<button
type="button"
onClick={onNewChat}
className="flex items-center justify-center size-9 shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="New Chat"
>
<Plus className="size-3.5" />
</button>
)}
</div>
);
}

View file

@ -74,6 +74,7 @@ Zero syncs the following tables for real-time features:
|-------|---------|
| `notifications` | Inbox (comments, document processing, connector status) |
| `documents` | Document list, processing status indicators |
| `folders` | Nested folder tree for organizing documents |
| `search_source_connectors` | Connector status, indexing progress |
| `new_chat_messages` | Live chat message sync for shared chats |
| `chat_comments` | Real-time comment threads on AI responses |

View file

@ -0,0 +1,65 @@
import { z } from "zod";
export const folder = z.object({
id: z.number(),
name: z.string(),
position: z.string(),
parent_id: z.number().nullable(),
search_space_id: z.number(),
created_by_id: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
});
export const folderCreateRequest = z.object({
name: z.string().min(1).max(255),
parent_id: z.number().nullable().optional(),
search_space_id: z.number(),
});
export const folderUpdateRequest = z.object({
name: z.string().min(1).max(255),
});
export const folderMoveRequest = z.object({
new_parent_id: z.number().nullable().optional(),
});
export const folderReorderRequest = z.object({
before_position: z.string().nullable().optional(),
after_position: z.string().nullable().optional(),
});
export const folderBreadcrumb = z.object({
id: z.number(),
name: z.string(),
});
export const documentMoveRequest = z.object({
folder_id: z.number().nullable().optional(),
});
export const bulkDocumentMoveRequest = z.object({
document_ids: z.array(z.number()),
folder_id: z.number().nullable().optional(),
});
export const folderListResponse = z.array(folder);
export const folderBreadcrumbResponse = z.array(folderBreadcrumb);
export const folderDeleteResponse = z.object({
message: z.string(),
documents_queued_for_deletion: z.number(),
});
export type Folder = z.infer<typeof folder>;
export type FolderCreateRequest = z.infer<typeof folderCreateRequest>;
export type FolderUpdateRequest = z.infer<typeof folderUpdateRequest>;
export type FolderMoveRequest = z.infer<typeof folderMoveRequest>;
export type FolderReorderRequest = z.infer<typeof folderReorderRequest>;
export type FolderBreadcrumb = z.infer<typeof folderBreadcrumb>;
export type DocumentMoveRequest = z.infer<typeof documentMoveRequest>;
export type BulkDocumentMoveRequest = z.infer<typeof bulkDocumentMoveRequest>;
export type FolderListResponse = z.infer<typeof folderListResponse>;
export type FolderBreadcrumbResponse = z.infer<typeof folderBreadcrumbResponse>;
export type FolderDeleteResponse = z.infer<typeof folderDeleteResponse>;

View file

@ -0,0 +1,107 @@
import {
type BulkDocumentMoveRequest,
type DocumentMoveRequest,
type FolderCreateRequest,
type FolderMoveRequest,
type FolderReorderRequest,
type FolderUpdateRequest,
bulkDocumentMoveRequest,
documentMoveRequest,
folder,
folderBreadcrumbResponse,
folderCreateRequest,
folderDeleteResponse,
folderListResponse,
folderMoveRequest,
folderReorderRequest,
folderUpdateRequest,
} from "@/contracts/types/folder.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class FoldersApiService {
createFolder = async (request: FolderCreateRequest) => {
const parsed = folderCreateRequest.safeParse(request);
if (!parsed.success) {
throw new ValidationError(`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
}
return baseApiService.post("/api/v1/folders", folder, { body: parsed.data });
};
listFolders = async (searchSpaceId: number) => {
return baseApiService.get(
`/api/v1/folders?search_space_id=${searchSpaceId}`,
folderListResponse,
);
};
getFolder = async (folderId: number) => {
return baseApiService.get(`/api/v1/folders/${folderId}`, folder);
};
getFolderBreadcrumb = async (folderId: number) => {
return baseApiService.get(
`/api/v1/folders/${folderId}/breadcrumb`,
folderBreadcrumbResponse,
);
};
updateFolder = async (folderId: number, request: FolderUpdateRequest) => {
const parsed = folderUpdateRequest.safeParse(request);
if (!parsed.success) {
throw new ValidationError(`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
}
return baseApiService.put(`/api/v1/folders/${folderId}`, folder, {
body: parsed.data,
});
};
moveFolder = async (folderId: number, request: FolderMoveRequest) => {
const parsed = folderMoveRequest.safeParse(request);
if (!parsed.success) {
throw new ValidationError(`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
}
return baseApiService.put(`/api/v1/folders/${folderId}/move`, folder, {
body: parsed.data,
});
};
reorderFolder = async (folderId: number, request: FolderReorderRequest) => {
const parsed = folderReorderRequest.safeParse(request);
if (!parsed.success) {
throw new ValidationError(`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
}
return baseApiService.put(`/api/v1/folders/${folderId}/reorder`, folder, {
body: parsed.data,
});
};
deleteFolder = async (folderId: number) => {
return baseApiService.delete(
`/api/v1/folders/${folderId}`,
folderDeleteResponse,
);
};
moveDocument = async (documentId: number, request: DocumentMoveRequest) => {
const parsed = documentMoveRequest.safeParse(request);
if (!parsed.success) {
throw new ValidationError(`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
}
return baseApiService.put(`/api/v1/documents/${documentId}/move`, undefined, {
body: parsed.data,
});
};
bulkMoveDocuments = async (request: BulkDocumentMoveRequest) => {
const parsed = bulkDocumentMoveRequest.safeParse(request);
if (!parsed.success) {
throw new ValidationError(`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
}
return baseApiService.put("/api/v1/documents/bulk-move", undefined, {
body: parsed.data,
});
};
}
export const foldersApiService = new FoldersApiService();

View file

@ -93,6 +93,7 @@
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.5",
"emblor": "^1.4.8",
"fractional-indexing": "^3.2.0",
"fumadocs-core": "^16.3.1",
"fumadocs-mdx": "^14.2.1",
"fumadocs-ui": "^16.3.1",

View file

@ -224,6 +224,9 @@ importers:
emblor:
specifier: ^1.4.8
version: 1.4.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
fractional-indexing:
specifier: ^3.2.0
version: 3.2.0
fumadocs-core:
specifier: ^16.3.1
version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
@ -5781,6 +5784,10 @@ packages:
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
fractional-indexing@3.2.0:
resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==}
engines: {node: ^14.13.1 || >=16.0.0}
framer-motion@12.34.3:
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
peerDependencies:
@ -14296,6 +14303,8 @@ snapshots:
forwarded-parse@2.1.2: {}
fractional-indexing@3.2.0: {}
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.34.3

View file

@ -0,0 +1,9 @@
import { defineQuery } from "@rocicorp/zero";
import { z } from "zod";
import { zql } from "../schema/index";
export const folderQueries = {
bySpace: defineQuery(z.object({ searchSpaceId: z.number() }), ({ args: { searchSpaceId } }) =>
zql.folders.where("searchSpaceId", searchSpaceId).orderBy("position", "asc")
),
};

View file

@ -1,11 +1,13 @@
import { defineQueries } from "@rocicorp/zero";
import { chatSessionQueries, commentQueries, messageQueries } from "./chat";
import { connectorQueries, documentQueries } from "./documents";
import { folderQueries } from "./folders";
import { notificationQueries } from "./inbox";
export const queries = defineQueries({
notifications: notificationQueries,
documents: documentQueries,
folders: folderQueries,
connectors: connectorQueries,
messages: messageQueries,
comments: commentQueries,

View file

@ -6,6 +6,7 @@ export const documentTable = table("documents")
title: string(),
documentType: string().from("document_type"),
searchSpaceId: number().from("search_space_id"),
folderId: number().optional().from("folder_id"),
createdById: string().optional().from("created_by_id"),
status: json(),
createdAt: number().from("created_at"),

View file

@ -0,0 +1,14 @@
import { number, string, table } from "@rocicorp/zero";
export const folderTable = table("folders")
.columns({
id: number(),
name: string(),
position: string(),
parentId: number().optional().from("parent_id"),
searchSpaceId: number().from("search_space_id"),
createdById: string().optional().from("created_by_id"),
createdAt: number().from("created_at"),
updatedAt: number().from("updated_at"),
})
.primaryKey("id");

View file

@ -1,6 +1,7 @@
import { createBuilder, createSchema, relationships } from "@rocicorp/zero";
import { chatCommentTable, chatSessionStateTable, newChatMessageTable } from "./chat";
import { documentTable, searchSourceConnectorTable } from "./documents";
import { folderTable } from "./folders";
import { notificationTable } from "./inbox";
const chatCommentRelationships = relationships(chatCommentTable, ({ one }) => ({
@ -28,6 +29,7 @@ export const schema = createSchema({
tables: [
notificationTable,
documentTable,
folderTable,
searchSourceConnectorTable,
newChatMessageTable,
chatCommentTable,