Date: Tue, 13 Jan 2026 01:48:43 -0800
Subject: [PATCH 3/7] feat: add custom callout component to MarkdownViewer for
enhanced styling
---
surfsense_web/components/markdown-viewer.tsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx
index 93e3f26e1..91f97830a 100644
--- a/surfsense_web/components/markdown-viewer.tsx
+++ b/surfsense_web/components/markdown-viewer.tsx
@@ -10,6 +10,14 @@ interface MarkdownViewerProps {
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const components: StreamdownProps["components"] = {
// Define custom components for markdown elements
+ callout: ({ children, ...props }) => (
+
+ {children}
+
+ ),
p: ({ children, ...props }) => (
{children}
From d140f6393e82d9eb51d3d32f058e84ef17359f1f Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Tue, 13 Jan 2026 02:06:35 -0800
Subject: [PATCH 4/7] feat: implement leave search space functionality for
non-owners and update related UI components
---
surfsense_backend/app/routes/rbac_routes.py | 93 ++++++++++---------
.../layout/providers/LayoutDataProvider.tsx | 82 +++++++++++++++-
.../contracts/types/search-space.types.ts | 7 ++
.../lib/apis/search-spaces-api.service.ts | 12 +++
surfsense_web/messages/en.json | 5 +
surfsense_web/messages/zh.json | 4 +
6 files changed, 154 insertions(+), 49 deletions(-)
diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py
index c5392f284..e90970b29 100644
--- a/surfsense_backend/app/routes/rbac_routes.py
+++ b/surfsense_backend/app/routes/rbac_routes.py
@@ -556,6 +556,54 @@ async def update_member_role(
) from e
+# NOTE: /members/me must be defined BEFORE /members/{membership_id}
+# because FastAPI matches routes in order, and "me" would otherwise
+# be interpreted as a membership_id (causing a 422 validation error)
+@router.delete("/searchspaces/{search_space_id}/members/me")
+async def leave_search_space(
+ search_space_id: int,
+ session: AsyncSession = Depends(get_async_session),
+ user: User = Depends(current_active_user),
+):
+ """
+ Leave a search space (remove own membership).
+ Owners cannot leave their search space.
+ """
+ try:
+ result = await session.execute(
+ select(SearchSpaceMembership).filter(
+ SearchSpaceMembership.user_id == user.id,
+ SearchSpaceMembership.search_space_id == search_space_id,
+ )
+ )
+ db_membership = result.scalars().first()
+
+ if not db_membership:
+ raise HTTPException(
+ status_code=404,
+ detail="You are not a member of this search space",
+ )
+
+ if db_membership.is_owner:
+ raise HTTPException(
+ status_code=400,
+ detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
+ )
+
+ await session.delete(db_membership)
+ await session.commit()
+ return {"message": "Successfully left the search space"}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ await session.rollback()
+ logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
+ raise HTTPException(
+ status_code=500, detail=f"Failed to leave search space: {e!s}"
+ ) from e
+
+
@router.delete("/searchspaces/{search_space_id}/members/{membership_id}")
async def remove_member(
search_space_id: int,
@@ -608,51 +656,6 @@ async def remove_member(
) from e
-@router.delete("/searchspaces/{search_space_id}/members/me")
-async def leave_search_space(
- search_space_id: int,
- session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
-):
- """
- Leave a search space (remove own membership).
- Owners cannot leave their search space.
- """
- try:
- result = await session.execute(
- select(SearchSpaceMembership).filter(
- SearchSpaceMembership.user_id == user.id,
- SearchSpaceMembership.search_space_id == search_space_id,
- )
- )
- db_membership = result.scalars().first()
-
- if not db_membership:
- raise HTTPException(
- status_code=404,
- detail="You are not a member of this search space",
- )
-
- if db_membership.is_owner:
- raise HTTPException(
- status_code=400,
- detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
- )
-
- await session.delete(db_membership)
- await session.commit()
- return {"message": "Successfully left the search space"}
-
- except HTTPException:
- raise
- except Exception as e:
- await session.rollback()
- logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
- raise HTTPException(
- status_code=500, detail=f"Failed to leave search space: {e!s}"
- ) from e
-
-
# ============ Invite Endpoints ============
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index dbc9c5f6a..3d4e5630d 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
-import { Logs, SquareLibrary, Trash2 } from "lucide-react";
+import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@@ -86,10 +86,13 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
- // Delete search space dialog state
+ // Delete/Leave search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
+ const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null);
+ const [searchSpaceToLeave, setSearchSpaceToLeave] = useState(null);
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
+ const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
@@ -181,8 +184,14 @@ export function LayoutDataProvider({
);
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
- setSearchSpaceToDelete(space);
- setShowDeleteSearchSpaceDialog(true);
+ // If user is owner, show delete dialog; otherwise show leave dialog
+ if (space.isOwner) {
+ setSearchSpaceToDelete(space);
+ setShowDeleteSearchSpaceDialog(true);
+ } else {
+ setSearchSpaceToLeave(space);
+ setShowLeaveSearchSpaceDialog(true);
+ }
}, []);
const confirmDeleteSearchSpace = useCallback(async () => {
@@ -215,6 +224,29 @@ export function LayoutDataProvider({
router,
]);
+ const confirmLeaveSearchSpace = useCallback(async () => {
+ if (!searchSpaceToLeave) return;
+ setIsLeavingSearchSpace(true);
+ try {
+ await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id);
+ refetchSearchSpaces();
+ if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) {
+ const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id);
+ if (remaining.length > 0) {
+ router.push(`/dashboard/${remaining[0].id}/new-chat`);
+ }
+ } else if (searchSpaces.length === 1) {
+ router.push("/dashboard");
+ }
+ } catch (error) {
+ console.error("Error leaving search space:", error);
+ } finally {
+ setIsLeavingSearchSpace(false);
+ setShowLeaveSearchSpaceDialog(false);
+ setSearchSpaceToLeave(null);
+ }
+ }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]);
+
const handleNavItemClick = useCallback(
(item: NavItem) => {
router.push(item.url);
@@ -422,6 +454,48 @@ export function LayoutDataProvider({
+ {/* Leave Search Space Dialog */}
+
+
{/* All Shared Chats Sidebar */}
;
export type GetSearchSpacesRequest = z.infer;
diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts
index ff60c513b..3e2006e46 100644
--- a/surfsense_web/lib/apis/search-spaces-api.service.ts
+++ b/surfsense_web/lib/apis/search-spaces-api.service.ts
@@ -11,6 +11,7 @@ import {
getSearchSpaceResponse,
getSearchSpacesRequest,
getSearchSpacesResponse,
+ leaveSearchSpaceResponse,
type UpdateSearchSpaceRequest,
updateSearchSpaceRequest,
updateSearchSpaceResponse,
@@ -115,6 +116,17 @@ class SearchSpacesApiService {
return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse);
};
+
+ /**
+ * Leave a search space (remove own membership)
+ * This is used by non-owners to leave a shared search space
+ */
+ leaveSearchSpace = async (searchSpaceId: number) => {
+ return baseApiService.delete(
+ `/api/v1/searchspaces/${searchSpaceId}/members/me`,
+ leaveSearchSpaceResponse
+ );
+ };
}
export const searchSpacesApiService = new SearchSpacesApiService();
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index d55f70bf7..c0c579070 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -100,6 +100,7 @@
"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.",
+ "leaving": "Leaving...",
"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"
@@ -162,6 +163,10 @@
"go_home": "Go Home",
"delete_search_space": "Delete Search Space",
"delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.",
+ "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.",
+ "leaving": "Leaving...",
"no_spaces_found": "No search spaces found",
"create_first_space": "Create your first search space to get started",
"created": "Created"
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index d0e6e50d7..f01ccda4b 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -159,6 +159,10 @@
"go_home": "返回首页",
"delete_search_space": "删除搜索空间",
"delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。",
+ "leave": "退出",
+ "leave_title": "退出搜索空间",
+ "leave_confirm": "您确定要退出\"{name}\"吗?您将无法访问此搜索空间中的所有文档和对话。",
+ "leaving": "退出中...",
"no_spaces_found": "未找到搜索空间",
"create_first_space": "创建您的第一个搜索空间以开始使用",
"created": "创建于"
From 8646fecc8b965a5607a23a25a5569940964461a8 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Tue, 13 Jan 2026 02:15:46 -0800
Subject: [PATCH 5/7] feat: add document upload functionality and update UI
components for document management
---
.../[search_space_id]/documents/(manage)/page.tsx | 10 ++++++++--
.../components/assistant-ui/document-upload-popup.tsx | 10 +++-------
surfsense_web/messages/en.json | 1 +
surfsense_web/messages/zh.json | 1 +
4 files changed, 13 insertions(+), 9 deletions(-)
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 7d266cafe..54fd490a1 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
@@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
-import { RefreshCw, SquarePlus } from "lucide-react";
+import { RefreshCw, SquarePlus, Upload } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
import { toast } from "sonner";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
+import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useLogsSummary } from "@/hooks/use-logs";
@@ -36,6 +37,7 @@ export default function DocumentsTable() {
const params = useParams();
const router = useRouter();
const searchSpaceId = Number(params.search_space_id);
+ const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const handleNewNote = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/editor/new`);
@@ -365,7 +367,11 @@ export default function DocumentsTable() {
{t("subtitle")}
-