feat: fixed issues of note management

Issues Fixed

- Missing pagination fields in API response schemas (page, page_size, has_more)
- NOTE enum missing from frontend Zod schema
- Missing fields in DocumentRead response construction (content_hash, updated_at)
- BlockNote slash menu clipped by overflow-hidden CSS
- Sidebar click conflicts - hidden action buttons intercepting clicks
- Rewrote All Notes sidebar - replaced fragile custom portal with shadcn Sheet
- Missing translation keys for new UI strings
- Missing NOTE retrieval logic in researcher agent
- Added search to All Notes sidebar
- Removed frontend logging - was causing toasters on every page refresh
- Added backend logging to document reindex Celery task
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-12-17 00:09:43 -08:00
parent 3c3527d498
commit c768730b8c
37 changed files with 758 additions and 740 deletions

View file

@ -492,6 +492,7 @@ async def fetch_documents_by_ids(
"CLICKUP_CONNECTOR": "ClickUp (Selected)",
"AIRTABLE_CONNECTOR": "Airtable (Selected)",
"LUMA_CONNECTOR": "Luma Events (Selected)",
"NOTE": "Notes (Selected)",
}
source_object = {
@ -1162,6 +1163,33 @@ async def fetch_relevant_documents(
}
)
elif connector == "NOTE":
(
source_object,
notes_chunks,
) = await connector_service.search_notes(
user_query=reformulated_query,
search_space_id=search_space_id,
top_k=top_k,
start_date=start_date,
end_date=end_date,
)
# Add to sources and raw documents
if source_object:
all_sources.append(source_object)
all_raw_documents.extend(notes_chunks)
# Stream found document count
if streaming_service and writer:
writer(
{
"yield_value": streaming_service.format_terminal_info_delta(
f"📝 Found {len(notes_chunks)} Notes related to your query"
)
}
)
except Exception as e:
logging.error("Error in search_airtable: %s", traceback.format_exc())
error_message = f"Error searching connector {connector}: {e!s}"

View file

@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str:
"LUMA_CONNECTOR": "",
"ELASTICSEARCH_CONNECTOR": "",
"WEBCRAWLER_CONNECTOR": "🌐",
"NOTE": "📝",
}
return connector_emojis.get(connector_name, "🔎")
@ -59,6 +60,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
"LUMA_CONNECTOR": "Luma",
"ELASTICSEARCH_CONNECTOR": "Elasticsearch",
"WEBCRAWLER_CONNECTOR": "Web Pages",
"NOTE": "Notes",
}
return connector_friendly_names.get(connector_name, connector_name)

View file

@ -266,12 +266,27 @@ async def read_documents(
document_type=doc.document_type,
document_metadata=doc.document_metadata,
content=doc.content,
content_hash=doc.content_hash,
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
)
)
return PaginatedResponse(items=api_documents, total=total)
# Calculate pagination info
actual_page = (
page if page is not None else (offset // page_size if page_size > 0 else 0)
)
has_more = (offset + len(api_documents)) < total if page_size > 0 else False
return PaginatedResponse(
items=api_documents,
total=total,
page=actual_page,
page_size=page_size,
has_more=has_more,
)
except HTTPException:
raise
except Exception as e:
@ -385,12 +400,27 @@ async def search_documents(
document_type=doc.document_type,
document_metadata=doc.document_metadata,
content=doc.content,
content_hash=doc.content_hash,
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
)
)
return PaginatedResponse(items=api_documents, total=total)
# Calculate pagination info
actual_page = (
page if page is not None else (offset // page_size if page_size > 0 else 0)
)
has_more = (offset + len(api_documents)) < total if page_size > 0 else False
return PaginatedResponse(
items=api_documents,
total=total,
page=actual_page,
page_size=page_size,
has_more=has_more,
)
except HTTPException:
raise
except Exception as e:
@ -510,7 +540,10 @@ async def get_document_by_chunk_id(
document_type=document.document_type,
document_metadata=document.document_metadata,
content=document.content,
content_hash=document.content_hash,
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
updated_at=document.updated_at,
search_space_id=document.search_space_id,
chunks=sorted_chunks,
)
@ -559,7 +592,10 @@ async def read_document(
document_type=document.document_type,
document_metadata=document.document_metadata,
content=document.content,
content_hash=document.content_hash,
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
updated_at=document.updated_at,
search_space_id=document.search_space_id,
)
except HTTPException:
@ -614,7 +650,10 @@ async def update_document(
document_type=db_document.document_type,
document_metadata=db_document.document_metadata,
content=db_document.content,
content_hash=db_document.content_hash,
unique_identifier_hash=db_document.unique_identifier_hash,
created_at=db_document.created_at,
updated_at=db_document.updated_at,
search_space_id=db_document.search_space_id,
)
except HTTPException:

View file

@ -172,7 +172,7 @@ async def save_document(
blocknote_document = data.get("blocknote_document")
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
# Add type validation
if not isinstance(blocknote_document, list):
raise HTTPException(status_code=400, detail="blocknote_document must be a list")

View file

@ -5,7 +5,7 @@ Notes routes for creating and managing BlockNote documents.
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -64,7 +64,6 @@ async def create_note(
content_hash = hashlib.sha256(request.title.encode()).hexdigest()
# Create document with NOTE type
from app.config import config
document = Document(
search_space_id=search_space_id,

View file

@ -46,7 +46,10 @@ class DocumentRead(BaseModel):
document_type: DocumentType
document_metadata: dict
content: str # Changed to string to match frontend
content_hash: str
unique_identifier_hash: str | None
created_at: datetime
updated_at: datetime | None
search_space_id: int
model_config = ConfigDict(from_attributes=True)
@ -61,3 +64,6 @@ class DocumentWithChunksRead(DocumentRead):
class PaginatedResponse[T](BaseModel):
items: list[T]
total: int
page: int
page_size: int
has_more: bool

View file

@ -2360,6 +2360,75 @@ class ConnectorService:
return result_object, elasticsearch_docs
async def search_notes(
self,
user_query: str,
search_space_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> tuple:
"""
Search for Notes and return both the source information and langchain documents.
Uses combined chunk-level and document-level hybrid search with RRF fusion.
Args:
user_query: The user's query
search_space_id: The search space ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
Returns:
tuple: (sources_info, langchain_documents)
"""
notes_docs = await self._combined_rrf_search(
query_text=user_query,
search_space_id=search_space_id,
document_type="NOTE",
top_k=top_k,
start_date=start_date,
end_date=end_date,
)
# Early return if no results
if not notes_docs:
return {
"id": 51,
"name": "Notes",
"type": "NOTE",
"sources": [],
}, []
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
return doc_info.get("title", "Untitled Note")
def _url_fn(_doc_info: dict[str, Any], _metadata: dict[str, Any]) -> str:
return "" # Notes don't have URLs
def _description_fn(
chunk: dict[str, Any], _doc_info: dict[str, Any], _metadata: dict[str, Any]
) -> str:
return self._chunk_preview(chunk.get("content", ""), limit=200)
sources_list = self._build_chunk_sources_from_documents(
notes_docs,
title_fn=_title_fn,
url_fn=_url_fn,
description_fn=_description_fn,
)
# Create result object
result_object = {
"id": 51,
"name": "Notes",
"type": "NOTE",
"sources": sources_list,
}
return result_object, notes_docs
async def search_bookstack(
self,
user_query: str,

View file

@ -3,6 +3,7 @@
import logging
from sqlalchemy import delete, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy.pool import NullPool
@ -11,6 +12,7 @@ from app.celery_app import celery_app
from app.config import config
from app.db import Document
from app.services.llm_service import get_user_long_context_llm
from app.services.task_logging_service import TaskLoggingService
from app.utils.blocknote_converter import convert_blocknote_to_markdown
from app.utils.document_converters import (
create_document_chunks,
@ -53,21 +55,42 @@ def reindex_document_task(self, document_id: int, user_id: str):
async def _reindex_document(document_id: int, user_id: str):
"""Async function to reindex a document."""
async with get_celery_session_maker()() as session:
# First, get the document to get search_space_id for logging
result = await session.execute(
select(Document)
.options(selectinload(Document.chunks))
.where(Document.id == document_id)
)
document = result.scalars().first()
if not document:
logger.error(f"Document {document_id} not found")
return
# Initialize task logger
task_logger = TaskLoggingService(session, document.search_space_id)
# Log task start
log_entry = await task_logger.log_task_start(
task_name="document_reindex",
source="editor",
message=f"Starting reindex for document: {document.title}",
metadata={
"document_id": document_id,
"document_type": document.document_type.value,
"title": document.title,
"user_id": user_id,
},
)
try:
# Get document
result = await session.execute(
select(Document)
.options(selectinload(Document.chunks)) # Eagerly load chunks
.where(Document.id == document_id)
)
document = result.scalars().first()
if not document:
logger.error(f"Document {document_id} not found")
return
if not document.blocknote_document:
logger.warning(f"Document {document_id} has no BlockNote content")
await task_logger.log_task_failure(
log_entry,
f"Document {document_id} has no BlockNote content to reindex",
"No BlockNote content",
{"error_type": "NoBlockNoteContent"},
)
return
logger.info(f"Reindexing document {document_id} ({document.title})")
@ -78,7 +101,12 @@ async def _reindex_document(document_id: int, user_id: str):
)
if not markdown_content:
logger.error(f"Failed to convert document {document_id} to markdown")
await task_logger.log_task_failure(
log_entry,
f"Failed to convert document {document_id} to markdown",
"Markdown conversion failed",
{"error_type": "ConversionError"},
)
return
# 2. Delete old chunks explicitly
@ -118,9 +146,39 @@ async def _reindex_document(document_id: int, user_id: str):
await session.commit()
# Log success
await task_logger.log_task_success(
log_entry,
f"Successfully reindexed document: {document.title}",
{
"chunks_created": len(new_chunks),
"document_id": document_id,
},
)
logger.info(f"Successfully reindexed document {document_id}")
except SQLAlchemyError as db_error:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Database error during reindex for document {document_id}",
str(db_error),
{"error_type": "SQLAlchemyError"},
)
logger.error(
f"Database error reindexing document {document_id}: {db_error}",
exc_info=True,
)
raise
except Exception as e:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Failed to reindex document: {document.title}",
str(e),
{"error_type": type(e).__name__},
)
logger.error(f"Error reindexing document {document_id}: {e}", exc_info=True)
raise

View file

@ -14,7 +14,6 @@ import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContain
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
@ -224,7 +223,6 @@ export function DashboardClientLayout({
</div>
<div className="flex items-center gap-2">
<LanguageSwitcher />
<ThemeTogglerComponent />
{/* Only show artifacts toggle on researcher page */}
{isResearcherPage && (
<motion.div

View file

@ -22,7 +22,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Separator } from "@/components/ui/separator";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { useLogs } from "@/hooks/use-logs";
interface EditorContent {
document_id: number;
@ -69,8 +68,6 @@ export default function EditorPage() {
const searchSpaceId = Number(params.search_space_id);
const isNewNote = documentId === "new";
const { createLog } = useLogs(searchSpaceId);
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -116,25 +113,6 @@ export default function EditorPage() {
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
const errorMessage = errorData.detail || "Failed to fetch document";
// Log fetch error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Failed to fetch document: ${errorMessage}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
error_type: "fetch_error",
http_status: response.status,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
throw new Error(errorMessage);
}
@ -142,48 +120,13 @@ export default function EditorPage() {
// Check if blocknote_document exists
if (!data.blocknote_document) {
const errorMsg = "This document does not have BlockNote content. Please re-upload the document to enable editing.";
// Log missing BlockNote content
try {
await createLog({
level: "WARNING",
status: "FAILED",
message: `Document ${documentId} does not have BlockNote content`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
error_type: "missing_blocknote_content",
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
const errorMsg =
"This document does not have BlockNote content. Please re-upload the document to enable editing.";
setError(errorMsg);
setLoading(false);
return;
}
// Log successful fetch
try {
await createLog({
level: "INFO",
status: "SUCCESS",
message: `Document ${documentId} loaded successfully`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
document_type: data.document_type,
title: data.title,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
@ -191,24 +134,6 @@ export default function EditorPage() {
console.error("Error fetching document:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
// Log general fetch error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Error fetching document: ${errorMessage}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
error_type: "fetch_exception",
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setError(errorMessage);
} finally {
setLoading(false);
@ -218,7 +143,7 @@ export default function EditorPage() {
if (documentId) {
fetchDocument();
}
}, [documentId, params.search_space_id, isNewNote, searchSpaceId, createLog]);
}, [documentId, params.search_space_id, isNewNote]);
// Track changes to mark as unsaved
useEffect(() => {
@ -280,49 +205,10 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
// Log save error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Failed to save new note: ${errorData.detail || "Unknown error"}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: note.id,
is_new_note: true,
action: "save",
http_status: response.status,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
throw new Error(errorData.detail || "Failed to save document");
}
}
// Log successful note creation
try {
await createLog({
level: "INFO",
status: "SUCCESS",
message: `Note created successfully: ${title}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: note.id,
is_new_note: true,
action: "save",
title: title,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background...");
@ -363,46 +249,9 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
// Log save error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Failed to save document ${documentId}: ${errorData.detail || "Unknown error"}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
action: "save",
http_status: response.status,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
throw new Error(errorData.detail || "Failed to save document");
}
// Log successful save
try {
await createLog({
level: "INFO",
status: "SUCCESS",
message: `Document ${documentId} saved successfully`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
action: "save",
title: document?.title,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
@ -421,26 +270,6 @@ export default function EditorPage() {
: isNewNote
? "Failed to create note. Please try again."
: "Failed to save document. Please try again.";
// Log save error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Error saving document: ${errorMessage}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: isNewNote ? null : documentId,
is_new_note: isNewNote,
action: "save",
error_type: "save_exception",
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setError(errorMessage);
toast.error(errorMessage);
} finally {
@ -557,7 +386,7 @@ export default function EditorPage() {
</div>
{/* Editor Container */}
<div className="flex-1 overflow-hidden relative">
<div className="flex-1 overflow-visible relative">
<div className="h-full w-full overflow-auto p-6">
{error && (
<motion.div

View file

@ -12,6 +12,7 @@ import {
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useAtomValue } from "jotai";
import {
ArrowLeft,
Calendar,
@ -45,12 +46,12 @@ import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { createRoleMutationAtom, updateRoleMutationAtom, deleteRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms";
import { useAtomValue } from "jotai";
import type { CreateRoleRequest, UpdateRoleRequest, DeleteRoleRequest, Role } from "@/contracts/types/roles.types";
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import {
createRoleMutationAtom,
deleteRoleMutationAtom,
updateRoleMutationAtom,
} from "@/atoms/roles/roles-mutation.atoms";
import {
AlertDialog,
AlertDialogAction,
@ -106,6 +107,12 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type {
CreateRoleRequest,
DeleteRoleRequest,
Role,
UpdateRoleRequest,
} from "@/contracts/types/roles.types";
import {
type Invite,
type InviteCreate,
@ -114,12 +121,14 @@ import {
useMembers,
useUserAccess,
} from "@/hooks/use-rbac";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
// Animation variants
const fadeInUp = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const} },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const } },
};
const staggerContainer = {
@ -183,7 +192,7 @@ export default function TeamManagementPage() {
);
const handleCreateRole = useCallback(
async (roleData: CreateRoleRequest['data']): Promise<Role> => {
async (roleData: CreateRoleRequest["data"]): Promise<Role> => {
const request: CreateRoleRequest = {
search_space_id: searchSpaceId,
data: roleData,
@ -1219,7 +1228,7 @@ function CreateRoleDialog({
onCreateRole,
}: {
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
onCreateRole: (data: CreateRoleRequest['data']) => Promise<Role>;
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);

View file

@ -8,6 +8,9 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown";
@ -35,10 +38,7 @@ import {
} from "@/components/ui/card";
import { Spotlight } from "@/components/ui/spotlight";
import { Tilt } from "@/components/ui/tilt";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { authenticatedFetch } from "@/lib/auth-utils";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
/**
* Formats a date string into a readable format

View file

@ -3,8 +3,8 @@
import { useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { SearchSpaceForm } from "@/components/search-space-form";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { SearchSpaceForm } from "@/components/search-space-form";
export default function SearchSpacesPage() {
const router = useRouter();

View file

@ -3,10 +3,10 @@ import { toast } from "sonner";
import type {
CreateRoleRequest,
CreateRoleResponse,
UpdateRoleRequest,
UpdateRoleResponse,
DeleteRoleRequest,
DeleteRoleResponse,
UpdateRoleRequest,
UpdateRoleResponse,
} from "@/contracts/types/roles.types";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -40,7 +40,10 @@ export const updateRoleMutationAtom = atomWithMutation(() => {
queryKey: cacheKeys.roles.all(request.search_space_id.toString()),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.roles.byId(request.search_space_id.toString(), request.role_id.toString()),
queryKey: cacheKeys.roles.byId(
request.search_space_id.toString(),
request.role_id.toString()
),
});
},
onError: () => {

View file

@ -2,13 +2,13 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateSearchSpaceRequest,
UpdateSearchSpaceRequest,
DeleteSearchSpaceRequest,
UpdateSearchSpaceRequest,
} from "@/contracts/types/search-space.types";
import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
export const createSearchSpaceMutationAtom = atomWithMutation(() => {
return {

View file

@ -1,5 +1,5 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { atom } from "jotai";
import { atomWithQuery } from "jotai-tanstack-query";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";

View file

@ -1,5 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
@ -13,10 +14,9 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { useQuery } from "@tanstack/react-query";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface {
label: string;
@ -44,7 +44,7 @@ export function DashboardBreadcrumb() {
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
// Skip fetch for "new" notes
if (documentId === "new") {
setDocumentTitle(null);
@ -124,7 +124,7 @@ export function DashboardBreadcrumb() {
} else {
documentLabel = documentTitle || subSection;
}
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,

View file

@ -1,8 +1,10 @@
"use client";
import { useAtomValue } from "jotai";
import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -12,9 +14,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { authenticatedFetch } from "@/lib/auth-utils";
import { useAtomValue } from "jotai";
interface SetupPromptStepProps {
searchSpaceId: number;

View file

@ -1,5 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
ChevronDown,
ChevronUp,
@ -12,6 +14,7 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -23,19 +26,20 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useQuery } from "@tanstack/react-query";
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { useAtomValue } from "jotai";
import { authenticatedFetch } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface PromptConfigManagerProps {
searchSpaceId: number;
}
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
const { data: searchSpace, isLoading: loading, refetch: fetchSearchSpace } = useQuery({
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,

View file

@ -1,5 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
@ -8,6 +9,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button";
import {
@ -18,11 +20,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useQuery } from "@tanstack/react-query";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
interface AppSidebarProviderProps {
searchSpaceId: string;

View file

@ -1,60 +1,36 @@
"use client";
import { FileText, type LucideIcon, MoreHorizontal, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
// Map of icon names to their components
const actionIconMap: Record<string, LucideIcon> = {
FileText,
Trash2,
MoreHorizontal,
RefreshCw,
};
interface NoteAction {
name: string;
icon: string;
onClick: () => void;
}
interface NoteItem {
name: string;
url: string;
icon: LucideIcon;
id?: number;
search_space_id?: number;
actions?: NoteAction[];
}
interface AllNotesSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onAddNote?: () => void;
hoverTimeoutRef?: React.MutableRefObject<NodeJS.Timeout | null>;
}
export function AllNotesSidebar({
@ -62,315 +38,242 @@ export function AllNotesSidebar({
onOpenChange,
searchSpaceId,
onAddNote,
hoverTimeoutRef,
}: AllNotesSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const sidebarRef = useRef<HTMLElement>(null);
const [sidebarLeft, setSidebarLeft] = useState(0); // Position from left edge of viewport
const queryClient = useQueryClient();
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
// Calculate the sidebar's right edge position
useEffect(() => {
if (typeof window === "undefined") return;
const updatePosition = () => {
// Find the actual sidebar element (the fixed positioned one)
const sidebarElement = document.querySelector(
'[data-slot="sidebar"][data-sidebar="sidebar"]'
) as HTMLElement;
if (sidebarElement) {
const rect = sidebarElement.getBoundingClientRect();
// Set the left position to be the right edge of the sidebar
setSidebarLeft(rect.right);
} else {
// Fallback: try to find any sidebar element
const fallbackSidebar = document.querySelector('[data-slot="sidebar"]') as HTMLElement;
if (fallbackSidebar) {
const rect = fallbackSidebar.getBoundingClientRect();
setSidebarLeft(rect.right);
} else {
// Final fallback: use CSS variable
const sidebarWidth = getComputedStyle(document.documentElement)
.getPropertyValue("--sidebar-width")
.trim();
if (sidebarWidth) {
const remValue = parseFloat(sidebarWidth);
setSidebarLeft(remValue * 16); // Convert rem to px
} else {
setSidebarLeft(256); // Default 16rem
}
}
}
};
updatePosition();
// Update on window resize and scroll
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
// Use MutationObserver to watch for sidebar state changes
const observer = new MutationObserver(updatePosition);
const sidebarWrapper = document.querySelector('[data-slot="sidebar-wrapper"]');
if (sidebarWrapper) {
observer.observe(sidebarWrapper, {
attributes: true,
attributeFilter: ["data-state", "class"],
childList: true,
subtree: true,
});
}
// Also observe the sidebar element directly if it exists
const sidebarElement = document.querySelector('[data-slot="sidebar"]');
if (sidebarElement) {
observer.observe(sidebarElement, {
attributes: true,
attributeFilter: ["data-state", "class"],
childList: false,
subtree: false,
});
}
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
observer.disconnect();
};
}, []);
// Handle Escape key to close sidebar
useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
// Fetch all notes
// Fetch all notes (when no search query)
const {
data: notesData,
error: notesError,
isLoading: isLoadingNotes,
refetch: refetchNotes,
} = useQuery({
queryKey: ["all-notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
page_size: 1000, // Get all notes
page_size: 1000,
}),
enabled: !!searchSpaceId && open, // Only fetch when sidebar is open
enabled: !!searchSpaceId && open && !debouncedSearchQuery,
});
// Handle note deletion with loading state
// Search notes (when there's a search query)
const {
data: searchData,
error: searchError,
isLoading: isSearching,
} = useQuery({
queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery],
queryFn: () =>
documentsApiService.searchDocuments({
queryParams: {
search_space_id: Number(searchSpaceId),
document_types: ["NOTE"],
title: debouncedSearchQuery,
page_size: 100,
},
}),
enabled: !!searchSpaceId && open && !!debouncedSearchQuery,
});
// Handle note navigation
const handleNoteClick = useCallback(
(noteId: number, noteSearchSpaceId: number) => {
router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`);
onOpenChange(false);
},
[router, onOpenChange]
);
// Handle note deletion
const handleDeleteNote = useCallback(
async (noteId: number, deleteAction: () => void) => {
setIsDeleting(noteId);
async (noteId: number, noteSearchSpaceId: number) => {
setDeletingNoteId(noteId);
try {
await deleteAction();
refetchNotes();
await notesApiService.deleteNote({
search_space_id: noteSearchSpaceId,
note_id: noteId,
});
// Invalidate queries to refresh the list
queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] });
} catch (error) {
console.error("Error deleting note:", error);
} finally {
setIsDeleting(null);
setDeletingNoteId(null);
}
},
[refetchNotes]
[queryClient, searchSpaceId]
);
// Transform notes to the format expected by the component
const allNotes = useMemo(() => {
return notesData?.items
? notesData.items.map((note) => ({
name: note.title,
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
icon: FileText as LucideIcon,
id: note.id,
search_space_id: note.search_space_id,
actions: [
{
name: "Delete",
icon: "Trash2",
onClick: async () => {
try {
await notesApiService.deleteNote({
search_space_id: note.search_space_id,
note_id: note.id,
});
} catch (error) {
console.error("Error deleting note:", error);
}
},
},
],
}))
: [];
}, [notesData]);
// Clear search
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
// Enhanced note item component
const NoteItemComponent = useCallback(
({ note }: { note: NoteItem }) => {
const isDeletingNote = isDeleting === note.id;
// Determine which data to show
const isSearchMode = !!debouncedSearchQuery;
const isLoading = isSearchMode ? isSearching : isLoadingNotes;
const error = isSearchMode ? searchError : notesError;
return (
<SidebarMenuItem key={note.id ? `note-${note.id}` : `note-${note.name}`}>
<SidebarMenuButton
onClick={() => {
router.push(note.url);
onOpenChange(false); // Close sidebar when navigating
}}
disabled={isDeletingNote}
className={cn("group/item relative", isDeletingNote && "opacity-50")}
>
<note.icon className="h-4 w-4 shrink-0" />
<span className={cn("truncate", isDeletingNote && "opacity-50")}>{note.name}</span>
</SidebarMenuButton>
// Transform notes data - handle both regular notes and search results
const notes = useMemo(() => {
if (isSearchMode && searchData?.items) {
return searchData.items.map((doc) => ({
id: doc.id,
title: doc.title,
search_space_id: doc.search_space_id,
}));
}
return notesData?.items ?? [];
}, [isSearchMode, searchData, notesData]);
{note.actions && note.actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="opacity-0 group-hover/item:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" side="left" align="start">
{note.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || FileText;
const isDeleteAction = action.name.toLowerCase().includes("delete");
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-80 p-0 flex flex-col">
<SheetHeader className="px-4 py-4 border-b space-y-3">
<SheetTitle>{t("all_notes") || "All Notes"}</SheetTitle>
<SheetDescription className="sr-only">
{t("all_notes_description") || "Browse and manage all your notes"}
</SheetDescription>
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={() => {
if (isDeleteAction) {
handleDeleteNote(note.id || 0, action.onClick);
} else {
action.onClick();
}
}}
disabled={isDeletingNote}
className={isDeleteAction ? "text-destructive" : ""}
>
<ActionIcon className="mr-2 h-4 w-4" />
<span>{isDeletingNote && isDeleteAction ? "Deleting..." : action.name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
},
[isDeleting, router, onOpenChange, handleDeleteNote]
);
{/* Search Input */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_notes") || "Search notes..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">Clear search</span>
</Button>
)}
</div>
</SheetHeader>
const sidebarContent = (
<section
ref={sidebarRef}
aria-label="All notes sidebar"
className={cn(
"fixed top-0 bottom-0 z-[100] w-80 bg-sidebar text-sidebar-foreground shadow-xl",
"transition-all duration-300 ease-out",
!open && "pointer-events-none"
)}
style={{
// Position it to slide from the right edge of the main sidebar
left: `${sidebarLeft}px`,
transform: open ? `scaleX(1)` : `scaleX(0)`,
transformOrigin: "left",
opacity: open ? 1 : 0,
}}
onMouseEnter={() => {
// Clear any pending close timeout when hovering over sidebar
if (hoverTimeoutRef?.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}}
onMouseLeave={() => {
// Close sidebar when mouse leaves
if (hoverTimeoutRef) {
hoverTimeoutRef.current = setTimeout(() => {
onOpenChange(false);
}, 200);
} else {
onOpenChange(false);
}
}}
>
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex h-16 shrink-0 items-center justify-between px-4 border-b border-sidebar">
<h2 className="text-sm font-semibold">{t("all_notes") || "All Notes"}</h2>
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="p-2">
<SidebarGroup>
<SidebarGroupContent>
{isLoadingNotes ? (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<span className="text-xs text-muted-foreground">
{t("loading") || "Loading..."}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
) : notesError ? (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<span className="text-xs text-destructive">
{t("error_loading_notes") || "Error loading notes"}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
) : allNotes.length > 0 ? (
<SidebarMenu className="list-none">
{allNotes.map((note) => (
<NoteItemComponent key={note.id || note.name} note={note} />
))}
</SidebarMenu>
) : (
<SidebarMenuItem className="list-none">
{onAddNote ? (
<SidebarMenuButton
onClick={() => {
onAddNote();
onOpenChange(false);
}}
className="text-muted-foreground hover:text-sidebar-foreground text-xs"
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_notes") || "Error loading notes"}
</div>
) : notes.length > 0 ? (
<div className="space-y-1">
{notes.map((note) => {
const isDeleting = deletingNoteId === note.id;
return (
<div
key={note.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isDeleting && "opacity-50 pointer-events-none"
)}
>
{/* Main clickable area for navigation */}
<button
type="button"
onClick={() => handleNoteClick(note.id, note.search_space_id)}
disabled={isDeleting}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<Plus className="h-4 w-4" />
<span>{t("create_new_note") || "Create a new note"}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton disabled className="text-muted-foreground text-xs">
<FileText className="h-4 w-4" />
<span>{t("no_notes") || "No notes yet"}</span>
</SidebarMenuButton>
)}
</SidebarMenuItem>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{note.title}</span>
</button>
{/* Actions dropdown - separate from main click area */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"opacity-0 group-hover:opacity-100 focus:opacity-100",
"transition-opacity"
)}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
)}
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={() => handleDeleteNote(note.id, note.search_space_id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No notes found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<FileText className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
{t("no_notes") || "No notes yet"}
</p>
{onAddNote && (
<Button
variant="outline"
size="sm"
onClick={() => {
onAddNote();
onOpenChange(false);
}}
>
<Plus className="mr-2 h-4 w-4" />
{t("create_new_note") || "Create a note"}
</Button>
)}
</SidebarGroupContent>
</SidebarGroup>
</div>
)}
</div>
</ScrollArea>
{/* Footer with Add Note button */}
{onAddNote && (
<div className="p-2">
{onAddNote && notes.length > 0 && (
<div className="p-3 border-t">
<Button
onClick={() => {
onAddNote();
@ -384,14 +287,7 @@ export function AllNotesSidebar({
</Button>
</div>
)}
</div>
</section>
</SheetContent>
</Sheet>
);
// Render sidebar via portal to avoid stacking context issues
if (typeof window === "undefined") {
return null;
}
return createPortal(sidebarContent, document.body);
}

View file

@ -1,5 +1,6 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
BookOpen,
@ -27,7 +28,7 @@ import {
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { memo, useEffect, useMemo, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
DropdownMenu,
DropdownMenuContent,
@ -37,8 +38,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
/**
* Generates a consistent color based on a string (email)
@ -454,7 +453,11 @@ export const AppSidebar = memo(function AppSidebar({
)}
<div className="space-y-2">
<NavNotes notes={processedRecentNotes} onAddNote={onAddNote} searchSpaceId={searchSpaceId} />
<NavNotes
notes={processedRecentNotes}
onAddNote={onAddNote}
searchSpaceId={searchSpaceId}
/>
</div>
</SidebarContent>
<SidebarFooter>

View file

@ -2,19 +2,18 @@
import {
ChevronRight,
ExternalLink,
Eye,
FileText,
FolderOpen,
Loader2,
type LucideIcon,
MoreHorizontal,
Plus,
RefreshCw,
Share,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState, useRef } from "react";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
@ -27,23 +26,12 @@ import {
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { AllNotesSidebar } from "./all-notes-sidebar";
// Map of icon names to their components
const actionIconMap: Record<string, LucideIcon> = {
ExternalLink,
FileText,
Share,
Trash2,
MoreHorizontal,
RefreshCw,
};
interface NoteAction {
name: string;
icon: string;
@ -66,14 +54,19 @@ interface NavNotesProps {
searchSpaceId?: string;
}
// Map of icon names to their components
const actionIconMap: Record<string, LucideIcon> = {
FileText,
Trash2,
MoreHorizontal,
};
export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
const t = useTranslations("sidebar");
const { isMobile } = useSidebar();
const router = useRouter();
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Handle note deletion with loading state
const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
@ -85,132 +78,148 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }
}
}, []);
// Enhanced note item component
const NoteItemComponent = useCallback(
({ note }: { note: NoteItem }) => {
const isDeletingNote = isDeleting === note.id;
return (
<SidebarMenuItem key={note.id ? `note-${note.id}` : `note-${note.name}`}>
<SidebarMenuButton
onClick={() => router.push(note.url)}
disabled={isDeletingNote}
className={`group/item relative ${isDeletingNote ? "opacity-50" : ""}`}
>
<note.icon className="h-4 w-4 shrink-0" />
<span className={`truncate ${isDeletingNote ? "opacity-50" : ""}`}>{note.name}</span>
</SidebarMenuButton>
{note.actions && note.actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="opacity-0 group-hover/item:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
{note.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || FileText;
const isDeleteAction = action.name.toLowerCase().includes("delete");
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={() => {
if (isDeleteAction) {
handleDeleteNote(note.id || 0, action.onClick);
} else {
action.onClick();
}
}}
disabled={isDeletingNote}
className={isDeleteAction ? "text-destructive" : ""}
>
<ActionIcon className="mr-2 h-4 w-4" />
<span>{isDeletingNote && isDeleteAction ? "Deleting..." : action.name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
// Handle note navigation
const handleNoteClick = useCallback(
(url: string) => {
router.push(url);
},
[isDeleting, router, isMobile, handleDeleteNote]
[router]
);
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden relative">
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center group/header relative">
<div className="flex items-center group/header">
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5">
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5 flex-1">
<ChevronRight
className={`h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0 hover:text-sidebar-foreground ${
isOpen ? "rotate-90" : ""
}`}
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0",
isOpen && "rotate-90"
)}
/>
<span>{t("notes") || "Notes"}</span>
</SidebarGroupLabel>
</CollapsibleTrigger>
<div className="absolute top-1.5 right-1 flex items-center gap-0.5 opacity-0 group-hover/header:opacity-100 transition-opacity">
{/* Action buttons - always visible on hover */}
<div className="flex items-center gap-0.5 opacity-0 group-hover/header:opacity-100 transition-opacity pr-1">
{searchSpaceId && notes.length > 0 && (
<button
type="button"
onMouseEnter={(e) => {
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
// Clear any pending close timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsAllNotesSidebarOpen(true);
}}
onMouseLeave={(e) => {
e.stopPropagation();
// Add a small delay before closing to allow moving to the sidebar
hoverTimeoutRef.current = setTimeout(() => {
setIsAllNotesSidebarOpen(false);
}, 200);
}}
aria-label="View all notes"
className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
>
<Eye className="h-4 w-4" />
</button>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
)}
{onAddNote && (
<button
type="button"
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
onAddNote();
}}
aria-label="Add note"
className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
>
<Plus className="h-4 w-4" />
</button>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{/* Note Items */}
{notes.length > 0 ? (
notes.map((note) => <NoteItemComponent key={note.id || note.name} note={note} />)
notes.map((note) => {
const isDeletingNote = isDeleting === note.id;
return (
<SidebarMenuItem key={note.id || note.name} className="group/note">
{/* Main navigation button */}
<SidebarMenuButton
onClick={() => handleNoteClick(note.url)}
disabled={isDeletingNote}
className={cn(
"pr-8", // Make room for the action button
isDeletingNote && "opacity-50"
)}
>
<note.icon className="h-4 w-4 shrink-0" />
<span className="truncate">{note.name}</span>
</SidebarMenuButton>
{/* Actions dropdown - positioned absolutely */}
{note.actions && note.actions.length > 0 && (
<div className="absolute right-1 top-1/2 -translate-y-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6",
"opacity-0 group-hover/note:opacity-100 focus:opacity-100",
"data-[state=open]:opacity-100",
"transition-opacity"
)}
disabled={isDeletingNote}
>
{isDeletingNote ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
)}
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right" className="w-40">
{note.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || FileText;
const isDeleteAction = action.name.toLowerCase().includes("delete");
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={() => {
if (isDeleteAction) {
handleDeleteNote(note.id || 0, action.onClick);
} else {
action.onClick();
}
}}
disabled={isDeletingNote}
className={
isDeleteAction
? "text-destructive focus:text-destructive"
: ""
}
>
<ActionIcon className="mr-2 h-4 w-4" />
<span>
{isDeletingNote && isDeleteAction
? "Deleting..."
: action.name}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</SidebarMenuItem>
);
})
) : (
/* Empty state with create button */
<SidebarMenuItem>
{onAddNote ? (
<SidebarMenuButton
@ -232,13 +241,14 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
{/* All Notes Sheet */}
{searchSpaceId && (
<AllNotesSidebar
open={isAllNotesSidebarOpen}
onOpenChange={setIsAllNotesSidebarOpen}
searchSpaceId={searchSpaceId}
onAddNote={onAddNote}
hoverTimeoutRef={hoverTimeoutRef}
/>
)}
</SidebarGroup>

View file

@ -60,4 +60,4 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
</SidebarGroupContent>
</SidebarGroup>
);
}
}

View file

@ -17,7 +17,17 @@ import {
IconTicket,
IconWorldWww,
} from "@tabler/icons-react";
import { File, FileText, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
import {
File,
FileText,
Globe,
Link,
Microscope,
Search,
Sparkles,
Telescope,
Webhook,
} from "lucide-react";
import { EnumConnectorName } from "./connector";
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {

View file

@ -19,6 +19,7 @@ export const documentTypeEnum = z.enum([
"LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR",
"LINEAR_CONNECTOR",
"NOTE",
]);
export const document = z.object({
@ -27,7 +28,10 @@ export const document = z.object({
document_type: documentTypeEnum,
document_metadata: z.record(z.string(), z.any()),
content: z.string(),
content_hash: z.string(),
unique_identifier_hash: z.string().nullable(),
created_at: z.string(),
updated_at: z.string().nullable(),
search_space_id: z.number(),
});
@ -68,6 +72,9 @@ export const getDocumentsRequest = z.object({
export const getDocumentsResponse = z.object({
items: z.array(document),
total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
});
/**
@ -118,6 +125,9 @@ export const searchDocumentsRequest = z.object({
export const searchDocumentsResponse = z.object({
items: z.array(document),
total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
});
/**

View file

@ -51,12 +51,14 @@ export const getRoleByIdResponse = role;
export const updateRoleRequest = z.object({
search_space_id: z.number(),
role_id: z.number(),
data: role.pick({
name: true,
description: true,
permissions: true,
is_default: true,
}).partial(),
data: role
.pick({
name: true,
description: true,
permissions: true,
is_default: true,
})
.partial(),
});
export const updateRoleResponse = role;

View file

@ -2,26 +2,26 @@ import { z } from "zod";
import { paginationQueryParams } from ".";
export const searchSpace = z.object({
id: z.number(),
name: z.string(),
description: z.string().nullable(),
created_at: z.string(),
user_id: z.string(),
citations_enabled: z.boolean(),
qna_custom_instructions: z.string().nullable(),
member_count: z.number(),
is_owner: z.boolean(),
id: z.number(),
name: z.string(),
description: z.string().nullable(),
created_at: z.string(),
user_id: z.string(),
citations_enabled: z.boolean(),
qna_custom_instructions: z.string().nullable(),
member_count: z.number(),
is_owner: z.boolean(),
});
/**
* Get search spaces
*/
export const getSearchSpacesRequest = z.object({
queryParams: paginationQueryParams
.extend({
owned_only: z.boolean().optional(),
})
.nullish(),
queryParams: paginationQueryParams
.extend({
owned_only: z.boolean().optional(),
})
.nullish(),
});
export const getSearchSpacesResponse = z.array(searchSpace);
@ -29,12 +29,10 @@ export const getSearchSpacesResponse = z.array(searchSpace);
/**
* Create search space
*/
export const createSearchSpaceRequest = searchSpace
.pick({ name: true, description: true })
.extend({
citations_enabled: z.boolean().default(true).optional(),
qna_custom_instructions: z.string().nullable().optional(),
});
export const createSearchSpaceRequest = searchSpace.pick({ name: true, description: true }).extend({
citations_enabled: z.boolean().default(true).optional(),
qna_custom_instructions: z.string().nullable().optional(),
});
export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
@ -42,13 +40,13 @@ export const createSearchSpaceResponse = searchSpace.omit({ member_count: true,
* Get community prompts
*/
export const getCommunityPromptsResponse = z.array(
z.object({
key: z.string(),
value: z.string(),
author: z.string(),
link: z.string(),
category: z.string(),
})
z.object({
key: z.string(),
value: z.string(),
author: z.string(),
link: z.string(),
category: z.string(),
})
);
/**
@ -62,10 +60,10 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_
* Update search space
*/
export const updateSearchSpaceRequest = z.object({
id: z.number(),
data: searchSpace
.pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
.partial(),
id: z.number(),
data: searchSpace
.pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
.partial(),
});
export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
@ -76,7 +74,7 @@ export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true,
export const deleteSearchSpaceRequest = searchSpace.pick({ id: true });
export const deleteSearchSpaceResponse = z.object({
message: z.literal("Search space deleted successfully"),
message: z.literal("Search space deleted successfully"),
});
// Inferred types

View file

@ -1,3 +1,4 @@
export * from "./use-debounced-value";
export * from "./use-logs";
export * from "./use-rbac";
export * from "./use-search-source-connectors";

View file

@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
/**
* Hook that returns a debounced value that only updates after the specified delay
* @param value - The value to debounce
* @param delay - The delay in milliseconds (default: 300ms)
* @returns The debounced value
*/
export function useDebouncedValue<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View file

@ -141,32 +141,43 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
);
// Function to create a new log
const createLog = useCallback(async (logData: Omit<Log, "id" | "created_at">) => {
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
{
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify(logData),
// Use silent: true to suppress toast notifications (for internal/background operations)
const createLog = useCallback(
async (logData: Omit<Log, "id" | "created_at">, options?: { silent?: boolean }) => {
const { silent = false } = options || {};
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
{
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify(logData),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to create log");
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to create log");
const newLog = await response.json();
setLogs((prevLogs) => [newLog, ...prevLogs]);
// Only show toast if not silent
if (!silent) {
toast.success("Log created successfully");
}
return newLog;
} catch (err: any) {
// Only show error toast if not silent
if (!silent) {
toast.error(err.message || "Failed to create log");
}
console.error("Error creating log:", err);
throw err;
}
const newLog = await response.json();
setLogs((prevLogs) => [newLog, ...prevLogs]);
toast.success("Log created successfully");
return newLog;
} catch (err: any) {
toast.error(err.message || "Failed to create log");
console.error("Error creating log:", err);
throw err;
}
}, []);
},
[]
);
// Function to update a log
const updateLog = useCallback(

View file

@ -145,4 +145,3 @@ class NotesApiService {
}
export const notesApiService = new NotesApiService();

View file

@ -6,9 +6,9 @@ import {
deleteRoleRequest,
deleteRoleResponse,
type GetRoleByIdRequest,
type GetRolesRequest,
getRoleByIdRequest,
getRoleByIdResponse,
type GetRolesRequest,
getRolesRequest,
getRolesResponse,
type UpdateRoleRequest,
@ -34,7 +34,7 @@ class RolesApiService {
createRoleResponse,
{
body: parsedRequest.data.data,
},
}
);
};
@ -50,7 +50,7 @@ class RolesApiService {
return baseApiService.get(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`,
getRolesResponse,
getRolesResponse
);
};
@ -66,7 +66,7 @@ class RolesApiService {
return baseApiService.get(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`,
getRoleByIdResponse,
getRoleByIdResponse
);
};
@ -85,7 +85,7 @@ class RolesApiService {
updateRoleResponse,
{
body: parsedRequest.data.data,
},
}
);
};
@ -101,7 +101,7 @@ class RolesApiService {
return baseApiService.delete(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`,
deleteRoleResponse,
deleteRoleResponse
);
};
}

View file

@ -1,18 +1,18 @@
import {
type CreateSearchSpaceRequest,
type DeleteSearchSpaceRequest,
type GetSearchSpaceRequest,
type GetSearchSpacesRequest,
type UpdateSearchSpaceRequest,
createSearchSpaceRequest,
createSearchSpaceResponse,
type DeleteSearchSpaceRequest,
deleteSearchSpaceRequest,
deleteSearchSpaceResponse,
type GetSearchSpaceRequest,
type GetSearchSpacesRequest,
getCommunityPromptsResponse,
getSearchSpaceRequest,
getSearchSpaceResponse,
getSearchSpacesRequest,
getSearchSpacesResponse,
type UpdateSearchSpaceRequest,
updateSearchSpaceRequest,
updateSearchSpaceResponse,
} from "@/contracts/types/search-space.types";
@ -71,7 +71,10 @@ class SearchSpacesApiService {
* Get community-curated prompts for search space system instructions
*/
getCommunityPrompts = async () => {
return baseApiService.get(`/api/v1/searchspaces/prompts/community`, getCommunityPromptsResponse);
return baseApiService.get(
`/api/v1/searchspaces/prompts/community`,
getCommunityPromptsResponse
);
};
/**

View file

@ -2,8 +2,8 @@ import type { GetChatsRequest } from "@/contracts/types/chat.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
import type { GetRolesRequest } from "@/contracts/types/roles.types";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
export const cacheKeys = {
chats: {

View file

@ -645,6 +645,10 @@
"search_space": "Search Space",
"notes": "Notes",
"all_notes": "All Notes",
"all_notes_description": "Browse and manage all your notes",
"search_notes": "Search notes...",
"no_results_found": "No notes found",
"try_different_search": "Try a different search term",
"no_notes": "No notes yet",
"create_new_note": "Create a new note",
"error_loading_notes": "Error loading notes",

View file

@ -645,6 +645,10 @@
"search_space": "搜索空间",
"notes": "笔记",
"all_notes": "所有笔记",
"all_notes_description": "浏览和管理您的所有笔记",
"search_notes": "搜索笔记...",
"no_results_found": "未找到笔记",
"try_different_search": "尝试其他搜索词",
"no_notes": "暂无笔记",
"create_new_note": "创建新笔记",
"error_loading_notes": "加载笔记时出错",