diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index ff9a8675b..fb5808307 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -56,6 +56,7 @@ router = APIRouter()
async def check_thread_access(
+ session: AsyncSession,
thread: NewChatThread,
user: User,
require_ownership: bool = False,
@@ -65,14 +66,16 @@ async def check_thread_access(
Access is granted if:
- User is the creator of the thread
- - Thread visibility is SEARCH_SPACE (and user has permission to read chats)
- - Thread is a legacy thread (created_by_id is NULL) - visible to all
+ - Thread visibility is SEARCH_SPACE (any member can access)
+ - Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner
Args:
+ session: Database session
thread: The thread to check access for
user: The user requesting access
require_ownership: If True, only the creator can access (for edit/delete operations)
- Legacy threads (NULL creator) are treated as accessible by all
+ For SEARCH_SPACE threads, any member with permission can access
+ Legacy threads (NULL creator) are accessible by search space owner
Returns:
True if access is granted
@@ -83,10 +86,31 @@ async def check_thread_access(
is_owner = thread.created_by_id == user.id
is_legacy = thread.created_by_id is None
- # Legacy threads are accessible to all users in the search space
- if is_legacy:
+ # Shared threads (SEARCH_SPACE) are accessible by any member
+ # This check comes first so shared threads are always accessible
+ if thread.visibility == ChatVisibility.SEARCH_SPACE:
+ # For ownership-required operations on shared threads, any member can proceed
+ # (permission check is done at route level)
return True
+ # For legacy threads (created before visibility feature),
+ # only the search space owner can access
+ if is_legacy:
+ search_space_query = select(SearchSpace).filter(
+ SearchSpace.id == thread.search_space_id
+ )
+ search_space_result = await session.execute(search_space_query)
+ search_space = search_space_result.scalar_one_or_none()
+ is_search_space_owner = search_space and search_space.user_id == user.id
+
+ if is_search_space_owner:
+ return True
+ # Legacy threads are not accessible to non-owners
+ raise HTTPException(
+ status_code=403,
+ detail="You don't have access to this chat",
+ )
+
# If ownership is required, only the creator can access
if require_ownership:
if not is_owner:
@@ -96,13 +120,10 @@ async def check_thread_access(
)
return True
- # For read access: owner or shared threads
+ # For read access: owner can access their own private threads
if is_owner:
return True
- if thread.visibility == ChatVisibility.SEARCH_SPACE:
- return True
-
# Private thread and user is not the owner
raise HTTPException(
status_code=403,
@@ -129,7 +150,7 @@ async def list_threads(
A user can see threads that are:
- Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE)
- - Legacy threads with no creator (created_by_id is NULL)
+ - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
Args:
search_space_id: The search space to list threads for
@@ -146,19 +167,32 @@ async def list_threads(
"You don't have permission to read chats in this search space",
)
- # Get threads that are either:
+ # Check if user is the search space owner (for legacy thread visibility)
+ search_space_query = select(SearchSpace).filter(
+ SearchSpace.id == search_space_id
+ )
+ search_space_result = await session.execute(search_space_query)
+ search_space = search_space_result.scalar_one_or_none()
+ is_search_space_owner = search_space and search_space.user_id == user.id
+
+ # Build filter conditions:
# 1. Created by the current user (any visibility)
# 2. Shared with the search space (visibility = SEARCH_SPACE)
- # 3. Legacy threads with no creator (created_by_id is NULL) - visible to all
+ # 3. Legacy threads (created_by_id is NULL) - only visible to search space owner
+ filter_conditions = [
+ NewChatThread.created_by_id == user.id,
+ NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
+ ]
+
+ # Only include legacy threads for the search space owner
+ if is_search_space_owner:
+ filter_conditions.append(NewChatThread.created_by_id.is_(None))
+
query = (
select(NewChatThread)
.filter(
NewChatThread.search_space_id == search_space_id,
- or_(
- NewChatThread.created_by_id == user.id,
- NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
- NewChatThread.created_by_id.is_(None), # Legacy threads
- ),
+ or_(*filter_conditions),
)
.order_by(NewChatThread.updated_at.desc())
)
@@ -171,9 +205,9 @@ async def list_threads(
archived_threads = []
for thread in all_threads:
- # Legacy threads (no creator) are treated as own threads for display purposes
- is_own_thread = (
- thread.created_by_id == user.id or thread.created_by_id is None
+ # Legacy threads (no creator) are treated as own threads for owner
+ is_own_thread = thread.created_by_id == user.id or (
+ thread.created_by_id is None and is_search_space_owner
)
item = ThreadListItem(
id=thread.id,
@@ -222,7 +256,7 @@ async def search_threads(
A user can search threads that are:
- Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE)
- - Legacy threads with no creator (created_by_id is NULL)
+ - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
Args:
search_space_id: The search space to search in
@@ -239,17 +273,31 @@ async def search_threads(
"You don't have permission to read chats in this search space",
)
+ # Check if user is the search space owner (for legacy thread visibility)
+ search_space_query = select(SearchSpace).filter(
+ SearchSpace.id == search_space_id
+ )
+ search_space_result = await session.execute(search_space_query)
+ search_space = search_space_result.scalar_one_or_none()
+ is_search_space_owner = search_space and search_space.user_id == user.id
+
+ # Build filter conditions
+ filter_conditions = [
+ NewChatThread.created_by_id == user.id,
+ NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
+ ]
+
+ # Only include legacy threads for the search space owner
+ if is_search_space_owner:
+ filter_conditions.append(NewChatThread.created_by_id.is_(None))
+
# Search accessible threads by title (case-insensitive)
query = (
select(NewChatThread)
.filter(
NewChatThread.search_space_id == search_space_id,
NewChatThread.title.ilike(f"%{title}%"),
- or_(
- NewChatThread.created_by_id == user.id,
- NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
- NewChatThread.created_by_id.is_(None), # Legacy threads
- ),
+ or_(*filter_conditions),
)
.order_by(NewChatThread.updated_at.desc())
)
@@ -264,9 +312,10 @@ async def search_threads(
archived=thread.archived,
visibility=thread.visibility,
created_by_id=thread.created_by_id,
- # Legacy threads (no creator) are treated as own threads
+ # Legacy threads (no creator) are treated as own threads for owner
is_own_thread=(
- thread.created_by_id == user.id or thread.created_by_id is None
+ thread.created_by_id == user.id
+ or (thread.created_by_id is None and is_search_space_owner)
),
created_at=thread.created_at,
updated_at=thread.updated_at,
@@ -383,7 +432,7 @@ async def get_thread_messages(
)
# Check thread-level access based on visibility
- await check_thread_access(thread, user)
+ await check_thread_access(session, thread, user)
# Return messages in the format expected by assistant-ui
messages = [
@@ -447,7 +496,7 @@ async def get_thread_full(
)
# Check thread-level access based on visibility
- await check_thread_access(thread, user)
+ await check_thread_access(session, thread, user)
return thread
@@ -500,7 +549,7 @@ async def update_thread(
# For PRIVATE threads, only the creator can update
# For SEARCH_SPACE threads, any member with permission can update
if db_thread.visibility == ChatVisibility.PRIVATE:
- await check_thread_access(db_thread, user, require_ownership=True)
+ await check_thread_access(session, db_thread, user, require_ownership=True)
# Update fields
update_data = thread_update.model_dump(exclude_unset=True)
@@ -568,7 +617,7 @@ async def delete_thread(
# For PRIVATE threads, only the creator can delete
# For SEARCH_SPACE threads, any member with permission can delete
if db_thread.visibility == ChatVisibility.PRIVATE:
- await check_thread_access(db_thread, user, require_ownership=True)
+ await check_thread_access(session, db_thread, user, require_ownership=True)
await session.delete(db_thread)
await session.commit()
@@ -628,7 +677,7 @@ async def update_thread_visibility(
)
# Only the creator can change visibility
- await check_thread_access(db_thread, user, require_ownership=True)
+ await check_thread_access(session, db_thread, user, require_ownership=True)
# Update visibility
db_thread.visibility = visibility_update.visibility
@@ -714,7 +763,7 @@ async def append_message(
)
# Check thread-level access based on visibility
- await check_thread_access(thread, user)
+ await check_thread_access(session, thread, user)
# Convert string role to enum
role_str = (
@@ -825,7 +874,7 @@ async def list_messages(
)
# Check thread-level access based on visibility
- await check_thread_access(thread, user)
+ await check_thread_access(session, thread, user)
# Get messages
query = (
@@ -894,7 +943,7 @@ async def handle_new_chat(
)
# Check thread-level access based on visibility
- await check_thread_access(thread, user)
+ await check_thread_access(session, thread, user)
# Get search space to check LLM config preferences
search_space_result = await session.execute(
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index 99b6e8703..a74f134dc 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -77,29 +77,31 @@ def format_mentioned_surfsense_docs_as_context(
return ""
import json
-
+
context_parts = [""]
context_parts.append(
"The user has explicitly mentioned the following SurfSense documentation pages. "
"These are official documentation about how to use SurfSense and should be used to answer questions about the application. "
"Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])."
)
-
+
for doc in documents:
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False)
-
+
context_parts.append("")
context_parts.append("")
context_parts.append(f" doc-{doc.id}")
context_parts.append(" SURFSENSE_DOCS")
context_parts.append(f" ")
context_parts.append(f" ")
- context_parts.append(f" ")
+ context_parts.append(
+ f" "
+ )
context_parts.append("")
context_parts.append("")
context_parts.append("")
-
- if hasattr(doc, 'chunks') and doc.chunks:
+
+ if hasattr(doc, "chunks") and doc.chunks:
for chunk in doc.chunks:
context_parts.append(
f" "
@@ -108,11 +110,11 @@ def format_mentioned_surfsense_docs_as_context(
context_parts.append(
f" "
)
-
+
context_parts.append("")
context_parts.append("")
context_parts.append("")
-
+
context_parts.append("")
return "\n".join(context_parts)
@@ -263,6 +265,7 @@ async def stream_new_chat(
mentioned_surfsense_docs: list[SurfsenseDocsDocument] = []
if mentioned_surfsense_doc_ids:
from sqlalchemy.orm import selectinload
+
result = await session.execute(
select(SurfsenseDocsDocument)
.options(selectinload(SurfsenseDocsDocument.chunks))
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
index 742be6ff4..7d266cafe 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
@@ -65,8 +65,7 @@ export default function DocumentsTable() {
);
// Check if only SURFSENSE_DOCS is selected (skip regular docs query)
- const onlySurfsenseDocsSelected =
- activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
+ const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
const queryParams = useMemo(
@@ -130,9 +129,11 @@ export default function DocumentsTable() {
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
queryFn: () =>
documentsApiService.getSurfsenseDocs({
- page: pageIndex,
- page_size: pageSize,
- title: debouncedSearch.trim() || undefined,
+ queryParams: {
+ page: pageIndex,
+ page_size: pageSize,
+ title: debouncedSearch.trim() || undefined,
+ },
}),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: showSurfsenseDocs,
@@ -256,7 +257,16 @@ export default function DocumentsTable() {
} finally {
setIsRefreshing(false);
}
- }, [debouncedSearch, refetchSearch, refetchDocuments, refetchSurfsenseDocs, showSurfsenseDocs, onlySurfsenseDocsSelected, t, isRefreshing]);
+ }, [
+ debouncedSearch,
+ refetchSearch,
+ refetchDocuments,
+ refetchSurfsenseDocs,
+ showSurfsenseDocs,
+ onlySurfsenseDocsSelected,
+ t,
+ isRefreshing,
+ ]);
// Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, {
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index 693aa3061..fbb92ae2c 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -459,7 +459,9 @@ export default function NewChatPage() {
// Track message sent
trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: messageAttachments.length > 0,
- hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0,
+ hasMentionedDocuments:
+ mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
+ mentionedDocumentIds.document_ids.length > 0,
messageLength: userQuery.length,
});
@@ -683,7 +685,9 @@ export default function NewChatPage() {
messages: messageHistory,
attachments: attachments.length > 0 ? attachments : undefined,
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
- mentioned_surfsense_doc_ids: hasSurfsenseDocIds ? mentionedDocumentIds.surfsense_doc_ids : undefined,
+ mentioned_surfsense_doc_ids: hasSurfsenseDocIds
+ ? mentionedDocumentIds.surfsense_doc_ids
+ : undefined,
}),
signal: controller.signal,
});
diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx
index 26cb882e2..fb2f49317 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx
@@ -170,7 +170,9 @@ function SettingsSidebar({
>
{t(item.labelKey)}
- {t(item.descriptionKey)}
+
+ {t(item.descriptionKey)}
+
-
>
);
diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx
index d09428a52..bf88e65e5 100644
--- a/surfsense_web/app/dashboard/user/settings/page.tsx
+++ b/surfsense_web/app/dashboard/user/settings/page.tsx
@@ -160,7 +160,6 @@ function UserSettingsSidebar({
);
})}
-
>
);
diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts
index 17ae38616..aba2736e5 100644
--- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts
+++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts
@@ -19,7 +19,7 @@ export const mentionedDocumentIdsAtom = atom<{
* Atom to store the full document objects mentioned in the current chat composer.
* This persists across component remounts.
*/
-export const mentionedDocumentsAtom = atom<(Pick)[]>([]);
+export const mentionedDocumentsAtom = atom[]>([]);
/**
* Simplified document info for display purposes
diff --git a/surfsense_web/components/assistant-ui/composer.tsx b/surfsense_web/components/assistant-ui/composer.tsx
index 417f7c70f..0e8c5bca5 100644
--- a/surfsense_web/components/assistant-ui/composer.tsx
+++ b/surfsense_web/components/assistant-ui/composer.tsx
@@ -54,8 +54,12 @@ export const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds({
- surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
- document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
+ surfsense_doc_ids: mentionedDocuments
+ .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
+ document_ids: mentionedDocuments
+ .filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
@@ -138,12 +142,14 @@ export const Composer: FC = () => {
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
- const updated = prev.filter(
- (doc) => !(doc.id === docId && doc.document_type === docType)
- );
+ const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
setMentionedDocumentIds({
- surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
- document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
+ surfsense_doc_ids: updated
+ .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
+ document_ids: updated
+ .filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
});
return updated;
});
@@ -153,9 +159,7 @@ export const Composer: FC = () => {
const handleDocumentsMention = useCallback(
(documents: Pick[]) => {
- const existingKeys = new Set(
- mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
- );
+ const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
@@ -171,8 +175,12 @@ export const Composer: FC = () => {
);
const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
- surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
- document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
+ surfsense_doc_ids: updated
+ .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
+ document_ids: updated
+ .filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
});
return updated;
});
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
index 4fa847a95..f35019216 100644
--- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
+++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
@@ -100,7 +100,9 @@ export const InlineMentionEditor = forwardRef {
if (initialDocuments.length > 0) {
- setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])));
+ setMentionedDocs(
+ new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
+ );
}
}, [initialDocuments]);
diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx
index 880832f1d..5700fbc2e 100644
--- a/surfsense_web/components/assistant-ui/markdown-text.tsx
+++ b/surfsense_web/components/assistant-ui/markdown-text.tsx
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts
-const CITATION_REGEX = /[\[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
+const CITATION_REGEX = /[[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 92b8ad786..bf46e3d97 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -230,8 +230,12 @@ const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds({
- surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
- document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
+ surfsense_doc_ids: mentionedDocuments
+ .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
+ document_ids: mentionedDocuments
+ .filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
@@ -314,12 +318,14 @@ const Composer: FC = () => {
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
- const updated = prev.filter(
- (doc) => !(doc.id === docId && doc.document_type === docType)
- );
+ const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
setMentionedDocumentIds({
- surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
- document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
+ surfsense_doc_ids: updated
+ .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
+ document_ids: updated
+ .filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
});
return updated;
});
@@ -329,9 +335,7 @@ const Composer: FC = () => {
const handleDocumentsMention = useCallback(
(documents: Pick[]) => {
- const existingKeys = new Set(
- mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
- );
+ const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
@@ -347,8 +351,12 @@ const Composer: FC = () => {
);
const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
- surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
- document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
+ surfsense_doc_ids: updated
+ .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
+ document_ids: updated
+ .filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
+ .map((doc) => doc.id),
});
return updated;
});
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index ce86deca5..dbc9c5f6a 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -86,6 +86,11 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
+ // Delete search space dialog state
+ const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
+ const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null);
+ const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
+
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
@@ -169,27 +174,46 @@ export function LayoutDataProvider({
}, [router]);
const handleSearchSpaceSettings = useCallback(
- (id: number) => {
- router.push(`/dashboard/${id}/settings`);
+ (space: SearchSpace) => {
+ router.push(`/dashboard/${space.id}/settings`);
},
[router]
);
- const handleDeleteSearchSpace = useCallback(
- async (id: number) => {
- await deleteSearchSpace({ id });
+ const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
+ setSearchSpaceToDelete(space);
+ setShowDeleteSearchSpaceDialog(true);
+ }, []);
+
+ const confirmDeleteSearchSpace = useCallback(async () => {
+ if (!searchSpaceToDelete) return;
+ setIsDeletingSearchSpace(true);
+ try {
+ await deleteSearchSpace({ id: searchSpaceToDelete.id });
refetchSearchSpaces();
- if (Number(searchSpaceId) === id && searchSpaces.length > 1) {
- const remaining = searchSpaces.filter((s) => s.id !== id);
+ if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) {
+ const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id);
if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`);
}
} else if (searchSpaces.length === 1) {
router.push("/dashboard");
}
- },
- [deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router]
- );
+ } catch (error) {
+ console.error("Error deleting search space:", error);
+ } finally {
+ setIsDeletingSearchSpace(false);
+ setShowDeleteSearchSpaceDialog(false);
+ setSearchSpaceToDelete(null);
+ }
+ }, [
+ searchSpaceToDelete,
+ deleteSearchSpace,
+ refetchSearchSpaces,
+ searchSpaceId,
+ searchSpaces,
+ router,
+ ]);
const handleNavItemClick = useCallback(
(item: NavItem) => {
@@ -284,6 +308,8 @@ export function LayoutDataProvider({
searchSpaces={searchSpaces}
activeSearchSpaceId={Number(searchSpaceId)}
onSearchSpaceSelect={handleSearchSpaceSelect}
+ onSearchSpaceDelete={handleSearchSpaceDeleteClick}
+ onSearchSpaceSettings={handleSearchSpaceSettings}
onAddSearchSpace={handleAddSearchSpace}
searchSpace={activeSearchSpace}
navItems={navItems}
@@ -297,9 +323,9 @@ export function LayoutDataProvider({
onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
- onSettings={handleSettings}
- onManageMembers={handleManageMembers}
- onUserSettings={handleUserSettings}
+ onSettings={handleSettings}
+ onManageMembers={handleManageMembers}
+ onUserSettings={handleUserSettings}
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}
@@ -354,6 +380,48 @@ export function LayoutDataProvider({
+ {/* Delete Search Space Dialog */}
+
+
{/* All Shared Chats Sidebar */}
void;
+ onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
+ onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void;
className?: string;
}
@@ -20,6 +22,8 @@ export function IconRail({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
+ onSearchSpaceDelete,
+ onSearchSpaceSettings,
onAddSearchSpace,
className,
}: IconRailProps) {
@@ -32,7 +36,13 @@ export function IconRail({
key={searchSpace.id}
name={searchSpace.name}
isActive={searchSpace.id === activeSearchSpaceId}
+ isShared={searchSpace.memberCount > 1}
+ isOwner={searchSpace.isOwner}
onClick={() => onSearchSpaceSelect(searchSpace.id)}
+ onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(searchSpace) : undefined}
+ onSettings={
+ onSearchSpaceSettings ? () => onSearchSpaceSettings(searchSpace) : undefined
+ }
size="md"
/>
))}
diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
index 77f4de899..1786c9c5e 100644
--- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
+++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
@@ -1,12 +1,25 @@
"use client";
+import { Settings, Trash2, Users } from "lucide-react";
+import { useTranslations } from "next-intl";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface SearchSpaceAvatarProps {
name: string;
isActive?: boolean;
+ isShared?: boolean;
+ isOwner?: boolean;
onClick?: () => void;
+ onDelete?: () => void;
+ onSettings?: () => void;
size?: "sm" | "md";
}
@@ -45,32 +58,103 @@ function getInitials(name: string): string {
export function SearchSpaceAvatar({
name,
isActive,
+ isShared,
+ isOwner = true,
onClick,
+ onDelete,
+ onSettings,
size = "md",
}: SearchSpaceAvatarProps) {
+ const t = useTranslations("searchSpace");
+ const tCommon = useTranslations("common");
const bgColor = stringToColor(name);
const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
+ const tooltipContent = (
+
+ {name}
+ {isShared && (
+
+ {isOwner ? tCommon("owner") : tCommon("shared")}
+
+ )}
+
+ );
+
+ const avatarButton = (
+
+ );
+
+ // If delete or settings handlers are provided, wrap with context menu
+ if (onDelete || onSettings) {
+ return (
+
+
+
+
+ {avatarButton}
+
+
+
+ {tooltipContent}
+
+
+
+ {onSettings && (
+
+
+ {tCommon("settings")}
+
+ )}
+ {onSettings && onDelete && }
+ {onDelete && isOwner && (
+
+
+ {tCommon("delete")}
+
+ )}
+ {onDelete && !isOwner && (
+
+
+ {t("leave")}
+
+ )}
+
+
+ );
+ }
+
+ // No context menu needed
return (
-
-
-
+ {avatarButton}
- {name}
+ {tooltipContent}
);
diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
index 251a67765..ed3a09099 100644
--- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
+++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
@@ -14,6 +14,8 @@ interface LayoutShellProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
+ onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
+ onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
@@ -46,6 +48,8 @@ export function LayoutShell({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
+ onSearchSpaceDelete,
+ onSearchSpaceSettings,
onAddSearchSpace,
searchSpace,
navItems,
@@ -96,6 +100,8 @@ export function LayoutShell({
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
+ onSearchSpaceDelete={onSearchSpaceDelete}
+ onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace}
searchSpace={searchSpace}
navItems={navItems}
@@ -133,6 +139,8 @@ export function LayoutShell({
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
+ onSearchSpaceDelete={onSearchSpaceDelete}
+ onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace}
/>
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index a57fd045c..57fba60c9 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -13,6 +13,8 @@ interface MobileSidebarProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
+ onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
+ onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
@@ -48,6 +50,8 @@ export function MobileSidebar({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
+ onSearchSpaceDelete,
+ onSearchSpaceSettings,
onAddSearchSpace,
searchSpace,
navItems,
@@ -94,7 +98,13 @@ export function MobileSidebar({
1}
+ isOwner={space.isOwner}
onClick={() => handleSearchSpaceSelect(space.id)}
+ onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined}
+ onSettings={
+ onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
+ }
size="md"
/>
@@ -111,33 +121,33 @@ export function MobileSidebar({
- {/* Sidebar Content */}
-
- {
- onNewChat();
- onOpenChange(false);
- }}
- onChatSelect={handleChatSelect}
- onChatDelete={onChatDelete}
- onViewAllSharedChats={onViewAllSharedChats}
- onViewAllPrivateChats={onViewAllPrivateChats}
- user={user}
- onSettings={onSettings}
- onManageMembers={onManageMembers}
- onUserSettings={onUserSettings}
- onLogout={onLogout}
- pageUsage={pageUsage}
- className="w-full border-none"
- />
-
+ {/* Sidebar Content */}
+
+ {
+ onNewChat();
+ onOpenChange(false);
+ }}
+ onChatSelect={handleChatSelect}
+ onChatDelete={onChatDelete}
+ onViewAllSharedChats={onViewAllSharedChats}
+ onViewAllPrivateChats={onViewAllPrivateChats}
+ user={user}
+ onSettings={onSettings}
+ onManageMembers={onManageMembers}
+ onUserSettings={onUserSettings}
+ onLogout={onLogout}
+ pageUsage={pageUsage}
+ className="w-full border-none"
+ />
+
);
diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx
index 8b84f3218..e89885b1d 100644
--- a/surfsense_web/components/new-chat/document-mention-picker.tsx
+++ b/surfsense_web/components/new-chat/document-mention-picker.tsx
@@ -57,7 +57,9 @@ export const DocumentMentionPicker = forwardRef<
const scrollContainerRef = useRef(null);
// State for pagination
- const [accumulatedDocuments, setAccumulatedDocuments] = useState[]>([]);
+ const [accumulatedDocuments, setAccumulatedDocuments] = useState<
+ Pick[]
+ >([]);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -128,7 +130,7 @@ export const DocumentMentionPicker = forwardRef<
useEffect(() => {
if (currentPage === 0) {
const combinedDocs: Pick[] = [];
-
+
// Add SurfSense docs first (they appear at top)
if (surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) {
@@ -139,7 +141,7 @@ export const DocumentMentionPicker = forwardRef<
});
}
}
-
+
// Add regular documents
if (debouncedSearch.trim()) {
if (searchedDocuments?.items) {
@@ -152,7 +154,7 @@ export const DocumentMentionPicker = forwardRef<
setHasMore(documents.has_more);
}
}
-
+
setAccumulatedDocuments(combinedDocs);
}
}, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
@@ -209,7 +211,9 @@ export const DocumentMentionPicker = forwardRef<
const actualDocuments = accumulatedDocuments;
const actualLoading =
- ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0;
+ ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) ||
+ isSurfsenseDocsLoading) &&
+ currentPage === 0;
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
const selectedKeys = useMemo(
diff --git a/surfsense_web/components/ui/context-menu.tsx b/surfsense_web/components/ui/context-menu.tsx
new file mode 100644
index 000000000..7c76f7fd0
--- /dev/null
+++ b/surfsense_web/components/ui/context-menu.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function ContextMenu({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuSub({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function ContextMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function ContextMenuContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function ContextMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function ContextMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function ContextMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function ContextMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function ContextMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+};
diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts
index e1047c386..bea399f98 100644
--- a/surfsense_web/lib/apis/documents-api.service.ts
+++ b/surfsense_web/lib/apis/documents-api.service.ts
@@ -19,6 +19,7 @@ import {
getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse,
+ getSurfsenseDocsRequest,
getSurfsenseDocsResponse,
type SearchDocumentsRequest,
searchDocumentsRequest,
@@ -29,7 +30,6 @@ import {
updateDocumentResponse,
uploadDocumentRequest,
uploadDocumentResponse,
- getSurfsenseDocsRequest,
} from "@/contracts/types/document.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index 9b59d5043..d55f70bf7 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -97,6 +97,9 @@
"create_new_search_space": "Create new search space",
"delete_title": "Delete Search Space",
"delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.",
+ "leave": "Leave",
+ "leave_title": "Leave Search Space",
+ "leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.",
"welcome_title": "Welcome to SurfSense",
"welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.",
"create_first_button": "Create your first search space"
diff --git a/surfsense_web/package.json b/surfsense_web/package.json
index 3c98c47e0..c3ee214eb 100644
--- a/surfsense_web/package.json
+++ b/surfsense_web/package.json
@@ -37,6 +37,7 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
+ "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml
index a184b6cd0..c85ddbf13 100644
--- a/surfsense_web/pnpm-lock.yaml
+++ b/surfsense_web/pnpm-lock.yaml
@@ -56,6 +56,9 @@ importers:
'@radix-ui/react-collapsible':
specifier: ^1.1.11
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-context-menu':
+ specifier: ^2.2.16
+ version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1628,6 +1631,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-context-menu@2.2.16':
+ resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-context@1.0.0':
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies:
@@ -7422,6 +7438,20 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
+ '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+
'@radix-ui/react-context@1.0.0(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.4