From e92b7a34ed6eb5fa91a7baf50d177649db0b0b19 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:35:12 +0530 Subject: [PATCH 01/26] feat(search-space): introduce activeWorkspaceIdAtom and refactor user-message component for improved readability - Added activeWorkspaceIdAtom as a reference to activeSearchSpaceIdAtom. - Refactored the UserTextPart component in user-message.tsx to simplify icon determination and tooltip logic, enhancing code clarity and maintainability. --- .../search-spaces/search-space-query.atoms.ts | 1 + .../components/assistant-ui/user-message.tsx | 51 +++++++++---------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts index 588466d90..e75843a8a 100644 --- a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts @@ -5,6 +5,7 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; export const activeSearchSpaceIdAtom = atom(null); +export const activeWorkspaceIdAtom = activeSearchSpaceIdAtom; export const searchSpacesQueryParamsAtom = atom({ skip: 0, diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 0c3649544..9eb2f3df1 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -118,40 +118,37 @@ const UserTextPart: FC = () => { if (segment.type === "text") { return {segment.value}; } - const isFolder = segment.doc.kind === "folder"; - const isConnector = segment.doc.kind === "connector"; - const isThread = segment.doc.kind === "thread"; - const icon = isFolder ? ( - - ) : isThread ? ( - - ) : isConnector ? ( - (getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? ( - - )) - ) : ( - getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5") - ); + const doc = segment.doc; + const icon = + doc.kind === "folder" ? ( + + ) : doc.kind === "thread" ? ( + + ) : doc.kind === "connector" ? ( + (getConnectorIcon(doc.connector_type, "size-3.5") ?? ) + ) : ( + getConnectorIcon(doc.document_type ?? "UNKNOWN", "size-3.5") + ); return ( handleOpenThread(segment.doc.id) - : isFolder || isConnector + doc.kind === "thread" + ? () => handleOpenThread(doc.id) + : doc.kind === "folder" || doc.kind === "connector" ? undefined - : () => handleOpenDoc(segment.doc.id, segment.doc.title) + : () => handleOpenDoc(doc.id, doc.title) } className="mx-0.5" /> From c379ab1155fd37a6006d3355df21145c93991bf8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:04:17 +0530 Subject: [PATCH 02/26] feat(workspace): refactor search space handling to workspace --- .../dashboard/[workspace_id]/onboard/page.tsx | 2 +- .../components/assistant-ui/markdown-text.tsx | 9 +-- .../components/assistant-ui/thread.tsx | 16 ++--- .../components/assistant-ui/user-message.tsx | 9 +-- .../components/editor-panel/memory.ts | 2 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 7 ++- .../layout/ui/sidebar/InboxSidebar.tsx | 3 +- .../components/layout/ui/sidebar/Sidebar.tsx | 3 +- .../components/new-chat/prompt-picker.tsx | 5 +- .../components/providers/I18nProvider.tsx | 2 +- .../settings/auto-reload-settings.tsx | 3 +- .../settings/buy-credits-content.tsx | 3 +- .../settings/earn-credits-content.tsx | 3 +- .../model-provider-connections-panel.tsx | 15 +++++ .../contracts/types/document.types.ts | 18 ++++-- .../contracts/types/members.types.ts | 61 ++++++++++++------- surfsense_web/contracts/types/roles.types.ts | 16 ++++- .../contracts/types/search-space.types.ts | 2 +- .../hooks/use-search-source-connectors.ts | 6 +- .../lib/apis/agent-permissions-api.service.ts | 8 +-- .../lib/apis/automations-api.service.ts | 9 ++- .../lib/apis/chat-comments-api.service.ts | 2 +- .../lib/apis/chat-threads-api.service.ts | 2 +- .../lib/apis/connectors-api.service.ts | 10 +-- .../lib/apis/documents-api.service.ts | 45 +++++++++----- surfsense_web/lib/apis/folders-api.service.ts | 10 +-- .../lib/apis/image-generations-api.service.ts | 2 +- surfsense_web/lib/apis/invites-api.service.ts | 8 +-- surfsense_web/lib/apis/logs-api.service.ts | 13 ++-- surfsense_web/lib/apis/members-api.service.ts | 10 +-- .../lib/apis/model-connections-api.service.ts | 33 ++++++++-- .../lib/apis/notifications-api.service.ts | 8 +-- .../lib/apis/podcasts-api.service.ts | 2 +- surfsense_web/lib/apis/prompts-api.service.ts | 5 +- surfsense_web/lib/apis/reports-api.service.ts | 2 +- surfsense_web/lib/apis/roles-api.service.ts | 10 +-- .../lib/apis/search-spaces-api.service.ts | 16 ++--- surfsense_web/lib/apis/stripe-api.service.ts | 6 +- .../apis/video-presentations-api.service.ts | 2 +- surfsense_web/lib/chat/thread-persistence.ts | 6 +- surfsense_web/lib/route-params.ts | 11 ++++ .../tests/fixtures/chat-thread.fixture.ts | 2 +- surfsense_web/tests/helpers/api/chat.ts | 2 +- surfsense_web/tests/helpers/api/connectors.ts | 6 +- surfsense_web/tests/helpers/api/documents.ts | 2 +- .../tests/helpers/api/search-spaces.ts | 4 +- surfsense_web/tests/smoke/chat-stream.spec.ts | 2 +- surfsense_web/zero/schema/automations.ts | 2 +- surfsense_web/zero/schema/chat.ts | 2 +- surfsense_web/zero/schema/documents.ts | 4 +- surfsense_web/zero/schema/folders.ts | 2 +- surfsense_web/zero/schema/inbox.ts | 2 +- surfsense_web/zero/schema/podcasts.ts | 2 +- 53 files changed, 268 insertions(+), 169 deletions(-) create mode 100644 surfsense_web/lib/route-params.ts diff --git a/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx index 861ceaa07..9da14ef4a 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx @@ -74,7 +74,7 @@ export default function OnboardPage() {

: ; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 3716c59aa..34ff4888f 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -108,6 +108,7 @@ import { useElectronAPI } from "@/hooks/use-platform"; import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { slideoutOpenedTickAtom } from "@/lib/layout-events"; +import { getWorkspaceIdNumber } from "@/lib/route-params"; import { cn } from "@/lib/utils"; import { DocumentMentionPicker, @@ -458,7 +459,9 @@ const Composer: FC = () => { const prevMentionedDocsRef = useRef>(new Map()); const documentPickerRef = useRef(null); const promptPickerRef = useRef(null); - const { search_space_id, chat_id } = useParams(); + const params = useParams(); + const workspaceId = getWorkspaceIdNumber(params); + const chat_id = params.chat_id; const aui = useAui(); // Desktop-only auto-focus; on mobile, programmatic focus would // summon the soft keyboard on every picker close / thread switch. @@ -824,7 +827,6 @@ const Composer: FC = () => { const handleDocumentsMention = useCallback( (mentions: MentionedDocumentInfo[]) => { - const parsedSearchSpaceId = Number(search_space_id); const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? []; const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc))); @@ -832,8 +834,8 @@ const Composer: FC = () => { const key = getMentionDocKey(mention); if (editorDocKeys.has(key)) continue; editorRef.current?.insertMentionChip(mention); - if (Number.isFinite(parsedSearchSpaceId)) { - promoteRecentMention(parsedSearchSpaceId, mention); + if (workspaceId) { + promoteRecentMention(workspaceId, mention); } // Track within the loop so a duplicate-in-batch can't double-insert. editorDocKeys.add(key); @@ -844,7 +846,7 @@ const Composer: FC = () => { setMentionQuery(""); setSuggestionAnchorPoint(null); }, - [search_space_id] + [workspaceId] ); useEffect(() => { @@ -893,7 +895,7 @@ const Composer: FC = () => { { diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 9eb2f3df1..e74d2b4ab 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -26,6 +26,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { parseMentionSegments } from "@/lib/chat/parse-mention-segments"; +import { getWorkspaceIdNumber } from "@/lib/route-params"; interface AuthorMetadata { displayName: string | null; @@ -75,13 +76,7 @@ const UserTextPart: FC = () => { const openEditorPanel = useSetAtom(openEditorPanelAtom); const router = useRouter(); const params = useParams(); - const searchSpaceIdParam = params?.search_space_id; - const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam) - ? Number(searchSpaceIdParam[0]) - : Number(searchSpaceIdParam); - const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId) - ? parsedSearchSpaceId - : undefined; + const resolvedSearchSpaceId = getWorkspaceIdNumber(params); const handleOpenDoc = useCallback( (docId: number, title: string) => { diff --git a/surfsense_web/components/editor-panel/memory.ts b/surfsense_web/components/editor-panel/memory.ts index 8c4dfc035..085837803 100644 --- a/surfsense_web/components/editor-panel/memory.ts +++ b/surfsense_web/components/editor-panel/memory.ts @@ -27,7 +27,7 @@ interface MemoryReadResponse { function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) { if (scope === "user") return "/api/v1/users/me/memory"; if (!searchSpaceId) throw new Error("Missing search space context"); - return `/api/v1/searchspaces/${searchSpaceId}/memory`; + return `/api/v1/workspaces/${searchSpaceId}/memory`; } export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index e70a9fec9..e3811cdd1 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -81,6 +81,7 @@ import { authenticatedFetch } from "@/lib/auth-fetch"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { buildBackendUrl } from "@/lib/env-config"; import { uploadFolderScan } from "@/lib/folder-sync-upload"; +import { getWorkspaceIdNumber } from "@/lib/route-params"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { queries } from "@/zero/queries/index"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; @@ -228,7 +229,7 @@ function AuthenticatedDocumentsSidebarBase({ const platformElectronAPI = useElectronAPI(); const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null; const { etlService } = useRuntimeConfig(); - const searchSpaceId = Number(params.search_space_id); + const searchSpaceId = getWorkspaceIdNumber(params) ?? 0; const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const openEditorPanel = useSetAtom(openEditorPanelAtom); const { data: agentFlags } = useAtomValue(agentFlagsAtom); @@ -828,7 +829,7 @@ function AuthenticatedDocumentsSidebarBase({ const endpoint = doc.document_type === "USER_MEMORY" ? buildBackendUrl("/api/v1/users/me/memory") - : buildBackendUrl(`/api/v1/searchspaces/${searchSpaceId}/memory`); + : buildBackendUrl(`/api/v1/workspaces/${searchSpaceId}/memory`); const response = await authenticatedFetch(endpoint, { method: "GET" }); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: "Export failed" })); @@ -1038,7 +1039,7 @@ function AuthenticatedDocumentsSidebarBase({ const endpoint = doc.document_type === "USER_MEMORY" ? buildBackendUrl("/api/v1/users/me/memory/reset") - : buildBackendUrl(`/api/v1/searchspaces/${searchSpaceId}/memory/reset`); + : buildBackendUrl(`/api/v1/workspaces/${searchSpaceId}/memory/reset`); try { const response = await authenticatedFetch(endpoint, { method: "POST" }); if (!response.ok) { diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 3785dc649..6f58e2d1a 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -58,6 +58,7 @@ import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { convertRenderedToDisplay } from "@/lib/comments/utils"; import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { getWorkspaceIdNumber } from "@/lib/route-params"; import { cn } from "@/lib/utils"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; @@ -165,7 +166,7 @@ export function InboxSidebarContent({ const router = useRouter(); const params = useParams(); const isMobile = !useMediaQuery("(min-width: 640px)"); - const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; + const searchSpaceId = getWorkspaceIdNumber(params) ?? null; const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index c274e1f97..6044508b0 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { useIsAnonymous } from "@/contexts/anonymous-mode"; +import { getWorkspaceIdParam } from "@/lib/route-params"; import { cn } from "@/lib/utils"; import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; @@ -377,7 +378,7 @@ function SidebarUsageFooter({ onNavigate?: () => void; }) { const params = useParams(); - const searchSpaceId = params?.search_space_id ?? ""; + const searchSpaceId = getWorkspaceIdParam(params) ?? ""; const isAnonymous = useIsAnonymous(); if (isCollapsed) return null; diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index 65bf0a889..d3dbb5309 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -24,6 +24,7 @@ import { ComposerSuggestionSeparator, ComposerSuggestionSkeleton, } from "@/components/new-chat/composer-suggestion-popup"; +import { getWorkspaceIdParam } from "@/lib/route-params"; export interface PromptPickerRef { selectHighlighted: () => void; @@ -69,9 +70,7 @@ export const PromptPicker = forwardRef(funct const createPromptIndex = filtered.length; const totalItems = filtered.length + 1; - const searchSpaceId = Array.isArray(params?.search_space_id) - ? params.search_space_id[0] - : params?.search_space_id; + const searchSpaceId = getWorkspaceIdParam(params); const handleSelect = useCallback( (index: number) => { diff --git a/surfsense_web/components/providers/I18nProvider.tsx b/surfsense_web/components/providers/I18nProvider.tsx index 679927163..be549cacf 100644 --- a/surfsense_web/components/providers/I18nProvider.tsx +++ b/surfsense_web/components/providers/I18nProvider.tsx @@ -11,7 +11,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { const { locale, messages } = useLocaleContext(); return ( - + {children} ); diff --git a/surfsense_web/components/settings/auto-reload-settings.tsx b/surfsense_web/components/settings/auto-reload-settings.tsx index fbb7cbfb9..61fe884a0 100644 --- a/surfsense_web/components/settings/auto-reload-settings.tsx +++ b/surfsense_web/components/settings/auto-reload-settings.tsx @@ -15,6 +15,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { AppError } from "@/lib/error"; +import { getWorkspaceIdNumber } from "@/lib/route-params"; import { queries } from "@/zero/queries"; const microsToDollars = (micros: number | null | undefined): string => { @@ -38,7 +39,7 @@ export function AutoReloadSettings() { const pathname = usePathname(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const searchSpaceId = Number(params?.search_space_id); + const searchSpaceId = getWorkspaceIdNumber(params) ?? 0; const [enabled, setEnabled] = useState(false); const [thresholdInput, setThresholdInput] = useState(""); diff --git a/surfsense_web/components/settings/buy-credits-content.tsx b/surfsense_web/components/settings/buy-credits-content.tsx index 8cb339420..4b1f2c87a 100644 --- a/surfsense_web/components/settings/buy-credits-content.tsx +++ b/surfsense_web/components/settings/buy-credits-content.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { AppError } from "@/lib/error"; +import { getWorkspaceIdNumber } from "@/lib/route-params"; import { cn } from "@/lib/utils"; import { queries } from "@/zero/queries"; @@ -37,7 +38,7 @@ const formatUsd = (micros: number) => { export function BuyCreditsContent() { const params = useParams(); - const searchSpaceId = Number(params?.search_space_id); + const searchSpaceId = getWorkspaceIdNumber(params) ?? 0; const [quantity, setQuantity] = useState(1); // Raw text of the amount field so the user can clear it while typing; // committed back to a clamped integer on blur. diff --git a/surfsense_web/components/settings/earn-credits-content.tsx b/surfsense_web/components/settings/earn-credits-content.tsx index 731ea7726..4637d7c72 100644 --- a/surfsense_web/components/settings/earn-credits-content.tsx +++ b/surfsense_web/components/settings/earn-credits-content.tsx @@ -20,6 +20,7 @@ import { trackIncentiveTaskClicked, trackIncentiveTaskCompleted, } from "@/lib/posthog/events"; +import { getWorkspaceIdParam } from "@/lib/route-params"; import { cn } from "@/lib/utils"; // Compact dollar label for a task's reward (e.g. "+$0.03"). @@ -32,7 +33,7 @@ const formatRewardUsd = (micros: number) => { export function EarnCreditsContent() { const params = useParams(); const queryClient = useQueryClient(); - const searchSpaceId = params?.search_space_id ?? ""; + const searchSpaceId = getWorkspaceIdParam(params) ?? ""; useEffect(() => { trackIncentivePageViewed(); diff --git a/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx b/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx index a703ab1c8..071f1c04d 100644 --- a/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx +++ b/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx @@ -126,6 +126,11 @@ export function ModelProviderConnectionsPanel({ // Each provider connect form builds its own credential payload; the backend // resolver (`to_litellm`) forwards `extra.litellm_params` straight to LiteLLM. function handleCreate(draft: ConnectionDraft) { + if (!Number.isFinite(searchSpaceId) || searchSpaceId <= 0) { + toast.error("Workspace is still loading. Please try again."); + return; + } + const models = connectionModelsForDraft(draft); const testModel = representativeTestModel(models); if (!testModel) { @@ -165,6 +170,11 @@ export function ModelProviderConnectionsPanel({ setProvider(providerId); setIsAddProviderOpen(true); if (providerId === "vertex_ai") { + if (!Number.isFinite(searchSpaceId) || searchSpaceId <= 0) { + toast.error("Workspace is still loading. Please try again."); + return; + } + previewModels.mutate( { provider: providerId, @@ -184,6 +194,11 @@ export function ModelProviderConnectionsPanel({ } function refreshConnectModels(draft: ConnectionDraft) { + if (!Number.isFinite(searchSpaceId) || searchSpaceId <= 0) { + toast.error("Workspace is still loading. Please try again."); + return; + } + previewModels.mutate( { provider, diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts index da1dac537..ec146ef90 100644 --- a/surfsense_web/contracts/types/document.types.ts +++ b/surfsense_web/contracts/types/document.types.ts @@ -200,12 +200,18 @@ export const documentTitleRead = z.object({ }); export const searchDocumentTitlesRequest = z.object({ - queryParams: z.object({ - search_space_id: z.number(), - title: z.string().optional(), - page: z.number().optional(), - page_size: z.number().optional(), - }), + queryParams: z + .object({ + search_space_id: z.number().optional(), + workspace_id: z.number().optional(), + title: z.string().optional(), + page: z.number().optional(), + page_size: z.number().optional(), + }) + .refine((params) => params.search_space_id !== undefined || params.workspace_id !== undefined, { + message: "workspace_id is required", + path: ["search_space_id"], + }), }); export const searchDocumentTitlesResponse = z.object({ diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts index e458807af..fee96e092 100644 --- a/surfsense_web/contracts/types/members.types.ts +++ b/surfsense_web/contracts/types/members.types.ts @@ -1,21 +1,32 @@ import { z } from "zod"; import { role } from "./roles.types"; -export const membership = z.object({ - id: z.number(), - user_id: z.string(), - search_space_id: z.number(), - role_id: z.number().nullable(), - is_owner: z.boolean(), - joined_at: z.string(), - created_at: z.string(), - role: role.nullable().optional(), - user_email: z.string().nullable().optional(), - user_display_name: z.string().nullable().optional(), - user_avatar_url: z.string().nullable().optional(), - user_last_login: z.string().nullable().optional(), - user_is_active: z.boolean().nullable().optional(), -}); +export const membership = z.preprocess( + (value) => { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const record = value as Record; + if (record.search_space_id === undefined && record.workspace_id !== undefined) { + return { ...record, search_space_id: record.workspace_id }; + } + } + return value; + }, + z.object({ + id: z.number(), + user_id: z.string(), + search_space_id: z.number(), + role_id: z.number().nullable(), + is_owner: z.boolean(), + joined_at: z.string(), + created_at: z.string(), + role: role.nullable().optional(), + user_email: z.string().nullable().optional(), + user_display_name: z.string().nullable().optional(), + user_avatar_url: z.string().nullable().optional(), + user_last_login: z.string().nullable().optional(), + user_is_active: z.boolean().nullable().optional(), + }) +); /** * Get members @@ -69,13 +80,19 @@ export const getMyAccessRequest = z.object({ search_space_id: z.number(), }); -export const getMyAccessResponse = z.object({ - search_space_name: z.string(), - search_space_id: z.number(), - is_owner: z.boolean(), - permissions: z.array(z.string()), - role_name: z.string().nullable(), -}); +export const getMyAccessResponse = z + .object({ + workspace_name: z.string(), + workspace_id: z.number(), + is_owner: z.boolean(), + permissions: z.array(z.string()), + role_name: z.string().nullable(), + }) + .transform(({ workspace_id, workspace_name, ...rest }) => ({ + ...rest, + search_space_id: workspace_id, + search_space_name: workspace_name, + })); export type Membership = z.infer; export type GetMembersRequest = z.infer; diff --git a/surfsense_web/contracts/types/roles.types.ts b/surfsense_web/contracts/types/roles.types.ts index 9008a859a..5c939b54c 100644 --- a/surfsense_web/contracts/types/roles.types.ts +++ b/surfsense_web/contracts/types/roles.types.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const role = z.object({ +const roleBase = z.object({ id: z.number(), name: z.string().min(1).max(100), description: z.string().max(500).nullable(), @@ -11,12 +11,22 @@ export const role = z.object({ created_at: z.string(), }); +export const role = z.preprocess((value) => { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const record = value as Record; + if (record.search_space_id === undefined && record.workspace_id !== undefined) { + return { ...record, search_space_id: record.workspace_id }; + } + } + return value; +}, roleBase); + /** * Create role */ export const createRoleRequest = z.object({ search_space_id: z.number(), - data: role.pick({ + data: roleBase.pick({ name: true, description: true, permissions: true, @@ -51,7 +61,7 @@ export const getRoleByIdResponse = role; export const updateRoleRequest = z.object({ search_space_id: z.number(), role_id: z.number(), - data: role + data: roleBase .pick({ name: true, description: true, diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts index c62b39074..acc0be07a 100644 --- a/surfsense_web/contracts/types/search-space.types.ts +++ b/surfsense_web/contracts/types/search-space.types.ts @@ -81,7 +81,7 @@ export const updateSearchSpaceApiAccessResponse = searchSpace.omit({ export const deleteSearchSpaceRequest = searchSpace.pick({ id: true }); export const deleteSearchSpaceResponse = z.object({ - message: z.literal("Search space deleted successfully"), + message: z.literal("Workspace deleted successfully"), }); /** diff --git a/surfsense_web/hooks/use-search-source-connectors.ts b/surfsense_web/hooks/use-search-source-connectors.ts index c2e9566b4..ba7bb350c 100644 --- a/surfsense_web/hooks/use-search-source-connectors.ts +++ b/surfsense_web/hooks/use-search-source-connectors.ts @@ -108,7 +108,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: const response = await authenticatedFetch( buildBackendUrl("/api/v1/search-source-connectors", { - search_space_id: spaceId, + workspace_id: spaceId, }), { method: "GET", @@ -167,7 +167,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: try { const response = await authenticatedFetch( buildBackendUrl("/api/v1/search-source-connectors", { - search_space_id: spaceId, + workspace_id: spaceId, }), { method: "POST", @@ -269,7 +269,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: try { const response = await authenticatedFetch( buildBackendUrl(`/api/v1/search-source-connectors/${connectorId}/index`, { - search_space_id: searchSpaceId, + workspace_id: searchSpaceId, start_date: startDate, end_date: endDate, }), diff --git a/surfsense_web/lib/apis/agent-permissions-api.service.ts b/surfsense_web/lib/apis/agent-permissions-api.service.ts index 6927c55d0..76bde0087 100644 --- a/surfsense_web/lib/apis/agent-permissions-api.service.ts +++ b/surfsense_web/lib/apis/agent-permissions-api.service.ts @@ -44,7 +44,7 @@ export type AgentPermissionRuleUpdate = z.infer => { return baseApiService.get( - `/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`, + `/api/v1/workspaces/${searchSpaceId}/agent/permissions/rules`, AgentPermissionRuleListSchema ); }; @@ -58,7 +58,7 @@ class AgentPermissionsApiService { throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", ")); } return baseApiService.post( - `/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`, + `/api/v1/workspaces/${searchSpaceId}/agent/permissions/rules`, AgentPermissionRuleSchema, { body: parsed.data } ); @@ -74,7 +74,7 @@ class AgentPermissionsApiService { throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", ")); } return baseApiService.patch( - `/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`, + `/api/v1/workspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`, AgentPermissionRuleSchema, { body: parsed.data } ); @@ -82,7 +82,7 @@ class AgentPermissionsApiService { remove = async (searchSpaceId: number, ruleId: number): Promise => { await baseApiService.delete( - `/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}` + `/api/v1/workspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}` ); }; } diff --git a/surfsense_web/lib/apis/automations-api.service.ts b/surfsense_web/lib/apis/automations-api.service.ts index baaf08799..f4783d8aa 100644 --- a/surfsense_web/lib/apis/automations-api.service.ts +++ b/surfsense_web/lib/apis/automations-api.service.ts @@ -37,7 +37,7 @@ class AutomationsApiService { listAutomations = async (params: AutomationListParams) => { const qs = new URLSearchParams({ - search_space_id: String(params.search_space_id), + workspace_id: String(params.search_space_id), limit: String(params.limit), offset: String(params.offset), }); @@ -50,7 +50,10 @@ class AutomationsApiService { createAutomation = async (request: AutomationCreateRequest) => { const data = rejectIfInvalid(automationCreateRequest.safeParse(request)); - return baseApiService.post(BASE, automation, { body: data }); + const { search_space_id, ...body } = data; + return baseApiService.post(BASE, automation, { + body: { ...body, workspace_id: search_space_id }, + }); }; updateAutomation = async (automationId: number, request: AutomationUpdateRequest) => { @@ -66,7 +69,7 @@ class AutomationsApiService { // Whether the search space's models are billable for automations (premium // global or BYOK). Used to gate creation surfaces before submit. getModelEligibility = async (searchSpaceId: number) => { - const qs = new URLSearchParams({ search_space_id: String(searchSpaceId) }); + const qs = new URLSearchParams({ workspace_id: String(searchSpaceId) }); return baseApiService.get(`${BASE}/model-eligibility?${qs.toString()}`, modelEligibility); }; diff --git a/surfsense_web/lib/apis/chat-comments-api.service.ts b/surfsense_web/lib/apis/chat-comments-api.service.ts index f1ec7a5d9..8d243f394 100644 --- a/surfsense_web/lib/apis/chat-comments-api.service.ts +++ b/surfsense_web/lib/apis/chat-comments-api.service.ts @@ -140,7 +140,7 @@ class ChatCommentsApiService { const params = new URLSearchParams(); if (parsed.data.search_space_id !== undefined) { - params.set("search_space_id", String(parsed.data.search_space_id)); + params.set("workspace_id", String(parsed.data.search_space_id)); } const queryString = params.toString(); diff --git a/surfsense_web/lib/apis/chat-threads-api.service.ts b/surfsense_web/lib/apis/chat-threads-api.service.ts index 9dcd85761..3941c6a4c 100644 --- a/surfsense_web/lib/apis/chat-threads-api.service.ts +++ b/surfsense_web/lib/apis/chat-threads-api.service.ts @@ -86,7 +86,7 @@ class ChatThreadsApiService { } return baseApiService.get( - `/api/v1/searchspaces/${parsed.data.search_space_id}/snapshots`, + `/api/v1/workspaces/${parsed.data.search_space_id}/snapshots`, publicChatSnapshotsBySpaceResponse ); }; diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts index 4b6d69883..92fc67a8d 100644 --- a/surfsense_web/lib/apis/connectors-api.service.ts +++ b/surfsense_web/lib/apis/connectors-api.service.ts @@ -59,7 +59,7 @@ class ConnectorsApiService { Object.entries(parsedRequest.data.queryParams) .filter(([_, v]) => v !== undefined && v !== null) .map(([k, v]) => { - return [k, String(v)]; + return [k === "search_space_id" ? "workspace_id" : k, String(v)]; }) ) : undefined; @@ -113,7 +113,7 @@ class ConnectorsApiService { Object.entries(queryParams) .filter(([_, v]) => v !== undefined && v !== null) .map(([k, v]) => { - return [k, String(v)]; + return [k === "search_space_id" ? "workspace_id" : k, String(v)]; }) ); @@ -187,7 +187,7 @@ class ConnectorsApiService { Object.entries(queryParams) .filter(([_, v]) => v !== undefined && v !== null) .map(([k, v]) => { - return [k, String(v)]; + return [k === "search_space_id" ? "workspace_id" : k, String(v)]; }) ); @@ -314,7 +314,7 @@ class ConnectorsApiService { const { search_space_id } = request.queryParams; const queryString = new URLSearchParams({ - search_space_id: String(search_space_id), + workspace_id: String(search_space_id), }).toString(); return baseApiService.get(`/api/v1/connectors/mcp?${queryString}`); @@ -334,7 +334,7 @@ class ConnectorsApiService { const { data, queryParams } = request; const queryString = new URLSearchParams({ - search_space_id: String(queryParams.search_space_id), + workspace_id: String(queryParams.search_space_id), }).toString(); return baseApiService.post( diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index 5b50db0c1..151b41a53 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -61,11 +61,12 @@ class DocumentsApiService { const transformedQueryParams = parsedRequest.data.queryParams ? Object.fromEntries( Object.entries(parsedRequest.data.queryParams).map(([k, v]) => { + const key = k === "search_space_id" ? "workspace_id" : k; // Handle array values (document_type) if (Array.isArray(v)) { - return [k, v.join(",")]; + return [key, v.join(",")]; } - return [k, String(v)]; + return [key, String(v)]; }) ) : undefined; @@ -106,8 +107,9 @@ class DocumentsApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } + const { search_space_id, ...body } = parsedRequest.data; return baseApiService.post(`/api/v1/documents`, createDocumentResponse, { - body: parsedRequest.data, + body: { ...body, workspace_id: search_space_id }, }); }; @@ -143,7 +145,7 @@ class DocumentsApiService { for (const batch of batches) { const formData = new FormData(); for (const file of batch) formData.append("files", file); - formData.append("search_space_id", String(search_space_id)); + formData.append("workspace_id", String(search_space_id)); formData.append("use_vision_llm", String(use_vision_llm)); formData.append("processing_mode", processing_mode); @@ -191,7 +193,7 @@ class DocumentsApiService { const { search_space_id, document_ids } = parsedRequest.data.queryParams; const params = new URLSearchParams({ - search_space_id: String(search_space_id), + workspace_id: String(search_space_id), document_ids: document_ids.join(","), }); @@ -218,11 +220,12 @@ class DocumentsApiService { const transformedQueryParams = parsedRequest.data.queryParams ? Object.fromEntries( Object.entries(parsedRequest.data.queryParams).map(([k, v]) => { + const key = k === "search_space_id" ? "workspace_id" : k; // Handle array values (document_type) if (Array.isArray(v)) { - return [k, v.join(",")]; + return [key, v.join(",")]; } - return [k, String(v)]; + return [key, String(v)]; }) ) : undefined; @@ -254,7 +257,10 @@ class DocumentsApiService { const transformedQueryParams = Object.fromEntries( Object.entries(parsedRequest.data.queryParams) .filter(([, v]) => v !== undefined) - .map(([k, v]) => [k, String(v)]) + .map(([k, v]) => [ + k === "search_space_id" || k === "workspace_id" ? "workspace_id" : k, + String(v), + ]) ); const queryParams = new URLSearchParams(transformedQueryParams).toString(); @@ -268,7 +274,7 @@ class DocumentsApiService { getDocumentByVirtualPath = async (request: { search_space_id: number; virtual_path: string }) => { const params = new URLSearchParams({ - search_space_id: String(request.search_space_id), + workspace_id: String(request.search_space_id), virtual_path: request.virtual_path, }); return baseApiService.get( @@ -295,7 +301,10 @@ class DocumentsApiService { // Transform query params to be string values const transformedQueryParams = parsedRequest.data.queryParams ? Object.fromEntries( - Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)]) + Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [ + k === "search_space_id" ? "workspace_id" : k, + String(v), + ]) ) : undefined; @@ -375,9 +384,10 @@ class DocumentsApiService { } const { id, data } = parsedRequest.data; + const { search_space_id, ...body } = data; return baseApiService.put(`/api/v1/documents/${id}`, updateDocumentResponse, { - body: data, + body: { ...body, workspace_id: search_space_id }, }); }; @@ -406,8 +416,9 @@ class DocumentsApiService { search_space_id: number; files: { relative_path: string; mtime: number }[]; }): Promise<{ files_to_upload: string[] }> => { + const { search_space_id, ...rest } = body; return baseApiService.post(`/api/v1/documents/folder-mtime-check`, undefined, { - body, + body: { ...rest, workspace_id: search_space_id }, }) as unknown as { files_to_upload: string[] }; }; @@ -428,7 +439,7 @@ class DocumentsApiService { formData.append("files", file); } formData.append("folder_name", metadata.folder_name); - formData.append("search_space_id", String(metadata.search_space_id)); + formData.append("workspace_id", String(metadata.search_space_id)); formData.append("relative_paths", JSON.stringify(metadata.relative_paths)); if (metadata.root_folder_id != null) { formData.append("root_folder_id", String(metadata.root_folder_id)); @@ -462,8 +473,9 @@ class DocumentsApiService { root_folder_id: number | null; relative_paths: string[]; }): Promise<{ deleted_count: number }> => { + const { search_space_id, ...rest } = body; return baseApiService.post(`/api/v1/documents/folder-unlink`, undefined, { - body, + body: { ...rest, workspace_id: search_space_id }, }) as unknown as { deleted_count: number }; }; @@ -473,14 +485,15 @@ class DocumentsApiService { root_folder_id: number | null; all_relative_paths: string[]; }): Promise<{ deleted_count: number }> => { + const { search_space_id, ...rest } = body; return baseApiService.post(`/api/v1/documents/folder-sync-finalize`, undefined, { - body, + body: { ...rest, workspace_id: search_space_id }, }) as unknown as { deleted_count: number }; }; getWatchedFolders = async (searchSpaceId: number) => { return baseApiService.get( - `/api/v1/documents/watched-folders?search_space_id=${searchSpaceId}`, + `/api/v1/documents/watched-folders?workspace_id=${searchSpaceId}`, folderListResponse ); }; diff --git a/surfsense_web/lib/apis/folders-api.service.ts b/surfsense_web/lib/apis/folders-api.service.ts index 2e535d615..54e0b41fc 100644 --- a/surfsense_web/lib/apis/folders-api.service.ts +++ b/surfsense_web/lib/apis/folders-api.service.ts @@ -27,14 +27,14 @@ class FoldersApiService { `Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}` ); } - return baseApiService.post("/api/v1/folders", folder, { body: parsed.data }); + const { search_space_id, ...body } = parsed.data; + return baseApiService.post("/api/v1/folders", folder, { + body: { ...body, workspace_id: search_space_id }, + }); }; listFolders = async (searchSpaceId: number) => { - return baseApiService.get( - `/api/v1/folders?search_space_id=${searchSpaceId}`, - folderListResponse - ); + return baseApiService.get(`/api/v1/folders?workspace_id=${searchSpaceId}`, folderListResponse); }; getFolder = async (folderId: number) => { diff --git a/surfsense_web/lib/apis/image-generations-api.service.ts b/surfsense_web/lib/apis/image-generations-api.service.ts index 6aa17854d..6f173dafc 100644 --- a/surfsense_web/lib/apis/image-generations-api.service.ts +++ b/surfsense_web/lib/apis/image-generations-api.service.ts @@ -9,7 +9,7 @@ const BASE = "/api/v1/image-generations"; class ImageGenerationsApiService { list = async (searchSpaceId: number, limit = 100) => { const qs = new URLSearchParams({ - search_space_id: String(searchSpaceId), + workspace_id: String(searchSpaceId), limit: String(limit), }).toString(); return baseApiService.get(`${BASE}?${qs}`, imageGenerationList); diff --git a/surfsense_web/lib/apis/invites-api.service.ts b/surfsense_web/lib/apis/invites-api.service.ts index 34889e094..7d86c1639 100644 --- a/surfsense_web/lib/apis/invites-api.service.ts +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -36,7 +36,7 @@ class InvitesApiService { } return baseApiService.post( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/invites`, createInviteResponse, { body: parsedRequest.data.data, @@ -58,7 +58,7 @@ class InvitesApiService { } return baseApiService.get( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/invites`, getInvitesResponse ); }; @@ -77,7 +77,7 @@ class InvitesApiService { } return baseApiService.put( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`, updateInviteResponse, { body: parsedRequest.data.data, @@ -99,7 +99,7 @@ class InvitesApiService { } return baseApiService.delete( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`, deleteInviteResponse ); }; diff --git a/surfsense_web/lib/apis/logs-api.service.ts b/surfsense_web/lib/apis/logs-api.service.ts index 700089354..17355100f 100644 --- a/surfsense_web/lib/apis/logs-api.service.ts +++ b/surfsense_web/lib/apis/logs-api.service.ts @@ -36,11 +36,12 @@ class LogsApiService { const transformedQueryParams = parsedRequest.data.queryParams ? Object.fromEntries( Object.entries(parsedRequest.data.queryParams).map(([k, v]) => { + const key = k === "search_space_id" ? "workspace_id" : k; // Handle array values (document_type) if (Array.isArray(v)) { - return [k, v.join(",")]; + return [key, v.join(",")]; } - return [k, String(v)]; + return [key, String(v)]; }) ) : undefined; @@ -74,8 +75,9 @@ class LogsApiService { const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); throw new ValidationError(`Invalid request: ${errorMessage}`); } + const { search_space_id, ...body } = parsedRequest.data; return baseApiService.post(`/api/v1/logs`, createLogResponse, { - body: parsedRequest.data, + body: { ...body, workspace_id: search_space_id }, }); }; @@ -89,8 +91,9 @@ class LogsApiService { const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); throw new ValidationError(`Invalid request: ${errorMessage}`); } + const { search_space_id, ...body } = parsedRequest.data; return baseApiService.put(`/api/v1/logs/${logId}`, updateLogResponse, { - body: parsedRequest.data, + body: search_space_id === undefined ? body : { ...body, workspace_id: search_space_id }, }); }; @@ -118,7 +121,7 @@ class LogsApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } const { search_space_id, hours } = parsedRequest.data; - const url = `/api/v1/logs/search-space/${search_space_id}/summary${hours ? `?hours=${hours}` : ""}`; + const url = `/api/v1/logs/workspaces/${search_space_id}/summary${hours ? `?hours=${hours}` : ""}`; return baseApiService.get(url, getLogSummaryResponse); }; } diff --git a/surfsense_web/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts index 8d0d19663..9b4017149 100644 --- a/surfsense_web/lib/apis/members-api.service.ts +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -33,7 +33,7 @@ class MembersApiService { } return baseApiService.get( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/members`, getMembersResponse ); }; @@ -52,7 +52,7 @@ class MembersApiService { } return baseApiService.put( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, updateMembershipResponse, { body: parsedRequest.data.data, @@ -74,7 +74,7 @@ class MembersApiService { } return baseApiService.delete( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, deleteMembershipResponse ); }; @@ -93,7 +93,7 @@ class MembersApiService { } return baseApiService.delete( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/me`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/members/me`, leaveSearchSpaceResponse ); }; @@ -112,7 +112,7 @@ class MembersApiService { } return baseApiService.get( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/my-access`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/my-access`, getMyAccessResponse ); }; diff --git a/surfsense_web/lib/apis/model-connections-api.service.ts b/surfsense_web/lib/apis/model-connections-api.service.ts index c69bcbef2..d8c6099ff 100644 --- a/surfsense_web/lib/apis/model-connections-api.service.ts +++ b/surfsense_web/lib/apis/model-connections-api.service.ts @@ -46,7 +46,7 @@ class ModelConnectionsApiService { getConnections = async (searchSpaceId: number): Promise => { return baseApiService.get( - `/api/v1/model-connections?search_space_id=${searchSpaceId}`, + `/api/v1/model-connections?workspace_id=${searchSpaceId}`, connectionListResponse ); }; @@ -56,8 +56,15 @@ class ModelConnectionsApiService { if (!parsed.success) { throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", ")); } + const { search_space_id, ...body } = parsed.data; + if ( + body.scope === "SEARCH_SPACE" && + (!Number.isFinite(search_space_id) || (search_space_id ?? 0) <= 0) + ) { + throw new ValidationError("workspace_id is required"); + } return baseApiService.post(`/api/v1/model-connections`, connectionRead, { - body: parsed.data, + body: { ...body, workspace_id: search_space_id }, }); }; @@ -91,11 +98,18 @@ class ModelConnectionsApiService { if (!parsed.success) { throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", ")); } + const { search_space_id, ...body } = parsed.data; + if ( + body.scope === "SEARCH_SPACE" && + (!Number.isFinite(search_space_id) || (search_space_id ?? 0) <= 0) + ) { + throw new ValidationError("workspace_id is required"); + } return baseApiService.post( `/api/v1/model-connections/discover-preview`, modelPreviewListResponse, { - body: parsed.data, + body: { ...body, workspace_id: search_space_id }, } ); }; @@ -107,8 +121,15 @@ class ModelConnectionsApiService { if (!parsed.success) { throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", ")); } + const { search_space_id, ...body } = parsed.data; + if ( + body.scope === "SEARCH_SPACE" && + (!Number.isFinite(search_space_id) || (search_space_id ?? 0) <= 0) + ) { + throw new ValidationError("workspace_id is required"); + } return baseApiService.post(`/api/v1/model-connections/test-preview`, verifyConnectionResponse, { - body: parsed.data, + body: { ...body, workspace_id: search_space_id }, }); }; @@ -159,11 +180,11 @@ class ModelConnectionsApiService { }; getModelRoles = async (searchSpaceId: number): Promise => { - return baseApiService.get(`/api/v1/search-spaces/${searchSpaceId}/model-roles`, modelRoles); + return baseApiService.get(`/api/v1/workspaces/${searchSpaceId}/model-roles`, modelRoles); }; updateModelRoles = async (searchSpaceId: number, roles: ModelRoles): Promise => { - return baseApiService.put(`/api/v1/search-spaces/${searchSpaceId}/model-roles`, modelRoles, { + return baseApiService.put(`/api/v1/workspaces/${searchSpaceId}/model-roles`, modelRoles, { body: roles, }); }; diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index bec28df29..6cb9c2147 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -42,7 +42,7 @@ class NotificationsApiService { const params = new URLSearchParams(); if (queryParams.search_space_id !== undefined) { - params.append("search_space_id", String(queryParams.search_space_id)); + params.append("workspace_id", String(queryParams.search_space_id)); } if (queryParams.type) { params.append("type", queryParams.type); @@ -113,7 +113,7 @@ class NotificationsApiService { getSourceTypes = async (searchSpaceId?: number): Promise => { const params = new URLSearchParams(); if (searchSpaceId !== undefined) { - params.append("search_space_id", String(searchSpaceId)); + params.append("workspace_id", String(searchSpaceId)); } const queryString = params.toString(); @@ -136,7 +136,7 @@ class NotificationsApiService { ): Promise => { const params = new URLSearchParams(); if (searchSpaceId !== undefined) { - params.append("search_space_id", String(searchSpaceId)); + params.append("workspace_id", String(searchSpaceId)); } if (type) { params.append("type", type); @@ -159,7 +159,7 @@ class NotificationsApiService { getBatchUnreadCounts = async (searchSpaceId?: number): Promise => { const params = new URLSearchParams(); if (searchSpaceId !== undefined) { - params.append("search_space_id", String(searchSpaceId)); + params.append("workspace_id", String(searchSpaceId)); } const queryString = params.toString(); diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index 3a18c7951..1108c8ca3 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -17,7 +17,7 @@ const voiceOptionList = z.array(voiceOption); class PodcastsApiService { list = async (searchSpaceId: number, limit = 200) => { const qs = new URLSearchParams({ - search_space_id: String(searchSpaceId), + workspace_id: String(searchSpaceId), limit: String(limit), }).toString(); return baseApiService.get(`${BASE}?${qs}`, podcastSummaryList); diff --git a/surfsense_web/lib/apis/prompts-api.service.ts b/surfsense_web/lib/apis/prompts-api.service.ts index 38c2ffb4e..359e920b8 100644 --- a/surfsense_web/lib/apis/prompts-api.service.ts +++ b/surfsense_web/lib/apis/prompts-api.service.ts @@ -15,7 +15,7 @@ class PromptsApiService { list = async (searchSpaceId?: number) => { const params = new URLSearchParams(); if (searchSpaceId !== undefined) { - params.set("search_space_id", String(searchSpaceId)); + params.set("workspace_id", String(searchSpaceId)); } const queryString = params.toString(); const url = queryString ? `/api/v1/prompts?${queryString}` : "/api/v1/prompts"; @@ -30,8 +30,9 @@ class PromptsApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } + const { search_space_id, ...body } = parsed.data; return baseApiService.post("/api/v1/prompts", promptRead, { - body: parsed.data, + body: { ...body, workspace_id: search_space_id }, }); }; diff --git a/surfsense_web/lib/apis/reports-api.service.ts b/surfsense_web/lib/apis/reports-api.service.ts index bc4483f37..e795e7f66 100644 --- a/surfsense_web/lib/apis/reports-api.service.ts +++ b/surfsense_web/lib/apis/reports-api.service.ts @@ -6,7 +6,7 @@ const BASE = "/api/v1/reports"; class ReportsApiService { list = async (searchSpaceId: number, limit = 200) => { const qs = new URLSearchParams({ - search_space_id: String(searchSpaceId), + workspace_id: String(searchSpaceId), limit: String(limit), }).toString(); return baseApiService.get(`${BASE}?${qs}`, reportList); diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts index 10023799b..6d08f7dff 100644 --- a/surfsense_web/lib/apis/roles-api.service.ts +++ b/surfsense_web/lib/apis/roles-api.service.ts @@ -30,7 +30,7 @@ class RolesApiService { } return baseApiService.post( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/roles`, createRoleResponse, { body: parsedRequest.data.data, @@ -49,7 +49,7 @@ class RolesApiService { } return baseApiService.get( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/roles`, getRolesResponse ); }; @@ -65,7 +65,7 @@ class RolesApiService { } return baseApiService.get( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, getRoleByIdResponse ); }; @@ -81,7 +81,7 @@ class RolesApiService { } return baseApiService.put( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, updateRoleResponse, { body: parsedRequest.data.data, @@ -100,7 +100,7 @@ class RolesApiService { } return baseApiService.delete( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + `/api/v1/workspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, deleteRoleResponse ); }; diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index 7f98399bd..3477a70f4 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -50,7 +50,7 @@ class SearchSpacesApiService { ? new URLSearchParams(transformedQueryParams).toString() : ""; - return baseApiService.get(`/api/v1/searchspaces?${queryParams}`, getSearchSpacesResponse); + return baseApiService.get(`/api/v1/workspaces?${queryParams}`, getSearchSpacesResponse); }; /** @@ -66,7 +66,7 @@ class SearchSpacesApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } - return baseApiService.post(`/api/v1/searchspaces`, createSearchSpaceResponse, { + return baseApiService.post(`/api/v1/workspaces`, createSearchSpaceResponse, { body: parsedRequest.data, }); }; @@ -84,7 +84,7 @@ class SearchSpacesApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } - return baseApiService.get(`/api/v1/searchspaces/${request.id}`, getSearchSpaceResponse); + return baseApiService.get(`/api/v1/workspaces/${request.id}`, getSearchSpaceResponse); }; /** @@ -100,7 +100,7 @@ class SearchSpacesApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } - return baseApiService.put(`/api/v1/searchspaces/${request.id}`, updateSearchSpaceResponse, { + return baseApiService.put(`/api/v1/workspaces/${request.id}`, updateSearchSpaceResponse, { body: parsedRequest.data.data, }); }; @@ -115,7 +115,7 @@ class SearchSpacesApiService { } return baseApiService.put( - `/api/v1/searchspaces/${request.id}/api-access`, + `/api/v1/workspaces/${request.id}/api-access`, updateSearchSpaceApiAccessResponse, { body: { api_access_enabled: parsedRequest.data.api_access_enabled }, @@ -136,7 +136,7 @@ class SearchSpacesApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } - return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse); + return baseApiService.delete(`/api/v1/workspaces/${request.id}`, deleteSearchSpaceResponse); }; /** @@ -144,7 +144,7 @@ class SearchSpacesApiService { */ triggerAiSort = async (searchSpaceId: number) => { return baseApiService.post( - `/api/v1/searchspaces/${searchSpaceId}/ai-sort`, + `/api/v1/workspaces/${searchSpaceId}/ai-sort`, z.object({ message: z.string() }), {} ); @@ -156,7 +156,7 @@ class SearchSpacesApiService { */ leaveSearchSpace = async (searchSpaceId: number) => { return baseApiService.delete( - `/api/v1/searchspaces/${searchSpaceId}/members/me`, + `/api/v1/workspaces/${searchSpaceId}/members/me`, leaveSearchSpaceResponse ); }; diff --git a/surfsense_web/lib/apis/stripe-api.service.ts b/surfsense_web/lib/apis/stripe-api.service.ts index b2f5698fb..cc91f6c38 100644 --- a/surfsense_web/lib/apis/stripe-api.service.ts +++ b/surfsense_web/lib/apis/stripe-api.service.ts @@ -23,10 +23,11 @@ class StripeApiService { createCreditCheckoutSession = async ( request: CreateCreditCheckoutSessionRequest ): Promise => { + const { search_space_id, ...body } = request; return baseApiService.post( "/api/v1/stripe/create-credit-checkout-session", createCreditCheckoutSessionResponse, - { body: request } + { body: { ...body, workspace_id: search_space_id } } ); }; @@ -74,10 +75,11 @@ class StripeApiService { createAutoReloadSetupSession = async ( request: CreateAutoReloadSetupSessionRequest ): Promise => { + const { search_space_id } = request; return baseApiService.post( "/api/v1/stripe/auto-reload/setup", createAutoReloadSetupSessionResponse, - { body: request } + { body: { workspace_id: search_space_id } } ); }; } diff --git a/surfsense_web/lib/apis/video-presentations-api.service.ts b/surfsense_web/lib/apis/video-presentations-api.service.ts index ef3ac21ed..4c8da510d 100644 --- a/surfsense_web/lib/apis/video-presentations-api.service.ts +++ b/surfsense_web/lib/apis/video-presentations-api.service.ts @@ -6,7 +6,7 @@ const BASE = "/api/v1/video-presentations"; class VideoPresentationsApiService { list = async (searchSpaceId: number, limit = 200) => { const qs = new URLSearchParams({ - search_space_id: String(searchSpaceId), + workspace_id: String(searchSpaceId), limit: String(limit), }).toString(); return baseApiService.get(`${BASE}?${qs}`, videoPresentationList); diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index dc5846f23..f3e808803 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -95,7 +95,7 @@ export async function fetchThreads( searchSpaceId: number, limit?: number ): Promise { - const params = new URLSearchParams({ search_space_id: String(searchSpaceId) }); + const params = new URLSearchParams({ workspace_id: String(searchSpaceId) }); if (limit) params.append("limit", String(limit)); return baseApiService.get(`/api/v1/threads?${params}`); } @@ -108,7 +108,7 @@ export async function searchThreads( title: string ): Promise { const params = new URLSearchParams({ - search_space_id: String(searchSpaceId), + workspace_id: String(searchSpaceId), title, }); return baseApiService.get(`/api/v1/threads/search?${params}`); @@ -125,7 +125,7 @@ export async function createThread( body: { title, archived: false, - search_space_id: searchSpaceId, + workspace_id: searchSpaceId, }, }); } diff --git a/surfsense_web/lib/route-params.ts b/surfsense_web/lib/route-params.ts new file mode 100644 index 000000000..02e5a8194 --- /dev/null +++ b/surfsense_web/lib/route-params.ts @@ -0,0 +1,11 @@ +type RouteParams = Record; + +export function getWorkspaceIdParam(params: RouteParams | null | undefined): string | undefined { + const value = params?.workspace_id ?? params?.search_space_id; + return Array.isArray(value) ? value[0] : value; +} + +export function getWorkspaceIdNumber(params: RouteParams | null | undefined): number | undefined { + const parsed = Number(getWorkspaceIdParam(params)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} diff --git a/surfsense_web/tests/fixtures/chat-thread.fixture.ts b/surfsense_web/tests/fixtures/chat-thread.fixture.ts index 81cf3b5c3..52eebf4a3 100644 --- a/surfsense_web/tests/fixtures/chat-thread.fixture.ts +++ b/surfsense_web/tests/fixtures/chat-thread.fixture.ts @@ -29,7 +29,7 @@ export const chatThreadFixtures = { headers: authHeaders(apiToken), data: { title: "e2e-drive-journey", - search_space_id: searchSpace.id, + workspace_id: searchSpace.id, visibility: "PRIVATE", }, }); diff --git a/surfsense_web/tests/helpers/api/chat.ts b/surfsense_web/tests/helpers/api/chat.ts index f5e1f92bb..ba01e2928 100644 --- a/surfsense_web/tests/helpers/api/chat.ts +++ b/surfsense_web/tests/helpers/api/chat.ts @@ -24,7 +24,7 @@ export async function streamChatToCompletion( headers: authHeaders(token), data: { chat_id: args.threadId, - search_space_id: args.searchSpaceId, + workspace_id: args.searchSpaceId, user_query: args.query, }, }); diff --git a/surfsense_web/tests/helpers/api/connectors.ts b/surfsense_web/tests/helpers/api/connectors.ts index 3d9b2ee59..1730fee67 100644 --- a/surfsense_web/tests/helpers/api/connectors.ts +++ b/surfsense_web/tests/helpers/api/connectors.ts @@ -16,7 +16,7 @@ export async function listConnectors( searchSpaceId: number ): Promise { const response = await request.get( - `${BACKEND_URL}/api/v1/search-source-connectors?search_space_id=${searchSpaceId}`, + `${BACKEND_URL}/api/v1/search-source-connectors?workspace_id=${searchSpaceId}`, { headers: authHeaders(token) } ); if (!response.ok()) { @@ -115,7 +115,7 @@ export async function triggerIndex( body: IndexBody ): Promise<{ ok: true }> { const response = await request.post( - `${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?search_space_id=${searchSpaceId}`, + `${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?workspace_id=${searchSpaceId}`, { headers: authHeaders(token), data: body } ); if (!response.ok()) { @@ -134,7 +134,7 @@ export async function triggerIndexExpectDisabled( body: IndexBody = {} ): Promise<{ indexing_started: false; message?: string }> { const response = await request.post( - `${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?search_space_id=${searchSpaceId}`, + `${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?workspace_id=${searchSpaceId}`, { headers: authHeaders(token), data: body } ); if (!response.ok()) { diff --git a/surfsense_web/tests/helpers/api/documents.ts b/surfsense_web/tests/helpers/api/documents.ts index 9658feead..48b8c297e 100644 --- a/surfsense_web/tests/helpers/api/documents.ts +++ b/surfsense_web/tests/helpers/api/documents.ts @@ -21,7 +21,7 @@ export async function listDocuments( limit = 100 ): Promise { const response = await request.get( - `${BACKEND_URL}/api/v1/documents?search_space_id=${searchSpaceId}&limit=${limit}`, + `${BACKEND_URL}/api/v1/documents?workspace_id=${searchSpaceId}&limit=${limit}`, { headers: authHeaders(token) } ); if (!response.ok()) { diff --git a/surfsense_web/tests/helpers/api/search-spaces.ts b/surfsense_web/tests/helpers/api/search-spaces.ts index 06274790e..a6f251c69 100644 --- a/surfsense_web/tests/helpers/api/search-spaces.ts +++ b/surfsense_web/tests/helpers/api/search-spaces.ts @@ -13,7 +13,7 @@ export async function createSearchSpace( name: string, description = "E2E test search space" ): Promise { - const response = await request.post(`${BACKEND_URL}/api/v1/searchspaces`, { + const response = await request.post(`${BACKEND_URL}/api/v1/workspaces`, { headers: authHeaders(token), data: { name, description }, }); @@ -28,7 +28,7 @@ export async function deleteSearchSpace( token: string, id: number ): Promise { - const response = await request.delete(`${BACKEND_URL}/api/v1/searchspaces/${id}`, { + const response = await request.delete(`${BACKEND_URL}/api/v1/workspaces/${id}`, { headers: authHeaders(token), }); if (!response.ok() && response.status() !== 404) { diff --git a/surfsense_web/tests/smoke/chat-stream.spec.ts b/surfsense_web/tests/smoke/chat-stream.spec.ts index 20fe67f43..8d69163a9 100644 --- a/surfsense_web/tests/smoke/chat-stream.spec.ts +++ b/surfsense_web/tests/smoke/chat-stream.spec.ts @@ -12,7 +12,7 @@ test.describe("Smoke", () => { headers: authHeaders(apiToken), data: { title: "e2e-chat-stream-smoke", - search_space_id: searchSpace.id, + workspace_id: searchSpace.id, visibility: "PRIVATE", }, }); diff --git a/surfsense_web/zero/schema/automations.ts b/surfsense_web/zero/schema/automations.ts index f9b89c533..d161731d9 100644 --- a/surfsense_web/zero/schema/automations.ts +++ b/surfsense_web/zero/schema/automations.ts @@ -3,7 +3,7 @@ import { json, number, string, table } from "@rocicorp/zero"; export const automationTable = table("automations") .columns({ id: number(), - searchSpaceId: number().from("search_space_id"), + searchSpaceId: number().from("workspace_id"), }) .primaryKey("id"); diff --git a/surfsense_web/zero/schema/chat.ts b/surfsense_web/zero/schema/chat.ts index 07229ac94..60d8e5c45 100644 --- a/surfsense_web/zero/schema/chat.ts +++ b/surfsense_web/zero/schema/chat.ts @@ -23,7 +23,7 @@ export const newChatMessageTable = table("new_chat_messages") export const newChatThreadTable = table("new_chat_threads") .columns({ id: number(), - searchSpaceId: number().from("search_space_id"), + searchSpaceId: number().from("workspace_id"), }) .primaryKey("id"); diff --git a/surfsense_web/zero/schema/documents.ts b/surfsense_web/zero/schema/documents.ts index 988056297..1065cd2c3 100644 --- a/surfsense_web/zero/schema/documents.ts +++ b/surfsense_web/zero/schema/documents.ts @@ -5,7 +5,7 @@ export const documentTable = table("documents") id: number(), title: string(), documentType: string().from("document_type"), - searchSpaceId: number().from("search_space_id"), + searchSpaceId: number().from("workspace_id"), folderId: number().optional().from("folder_id"), createdById: string().optional().from("created_by_id"), status: json(), @@ -24,7 +24,7 @@ export const searchSourceConnectorTable = table("search_source_connectors") periodicIndexingEnabled: boolean().from("periodic_indexing_enabled"), indexingFrequencyMinutes: number().optional().from("indexing_frequency_minutes"), nextScheduledAt: number().optional().from("next_scheduled_at"), - searchSpaceId: number().from("search_space_id"), + searchSpaceId: number().from("workspace_id"), userId: string().from("user_id"), createdAt: number().from("created_at"), }) diff --git a/surfsense_web/zero/schema/folders.ts b/surfsense_web/zero/schema/folders.ts index c5b192942..a2428da7a 100644 --- a/surfsense_web/zero/schema/folders.ts +++ b/surfsense_web/zero/schema/folders.ts @@ -6,7 +6,7 @@ export const folderTable = table("folders") name: string(), position: string(), parentId: number().optional().from("parent_id"), - searchSpaceId: number().from("search_space_id"), + searchSpaceId: number().from("workspace_id"), createdById: string().optional().from("created_by_id"), createdAt: number().from("created_at"), updatedAt: number().from("updated_at"), diff --git a/surfsense_web/zero/schema/inbox.ts b/surfsense_web/zero/schema/inbox.ts index 946180ba4..15991ed20 100644 --- a/surfsense_web/zero/schema/inbox.ts +++ b/surfsense_web/zero/schema/inbox.ts @@ -4,7 +4,7 @@ export const notificationTable = table("notifications") .columns({ id: number(), userId: string().from("user_id"), - searchSpaceId: number().optional().from("search_space_id"), + searchSpaceId: number().optional().from("workspace_id"), type: string(), title: string(), message: string(), diff --git a/surfsense_web/zero/schema/podcasts.ts b/surfsense_web/zero/schema/podcasts.ts index d473d776f..1b29d3161 100644 --- a/surfsense_web/zero/schema/podcasts.ts +++ b/surfsense_web/zero/schema/podcasts.ts @@ -12,7 +12,7 @@ export const podcastTable = table("podcasts") specVersion: number().from("spec_version"), durationSeconds: number().optional().from("duration_seconds"), error: string().optional(), - searchSpaceId: number().from("search_space_id"), + searchSpaceId: number().from("workspace_id"), threadId: number().optional().from("thread_id"), createdAt: number().from("created_at"), }) From ec617c1c6b591197695762f3f36f40450ef5f7f0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:09:30 +0530 Subject: [PATCH 03/26] refactor(workspace): update terminology from search space to workspace - Changed references from "search space" to "workspace" in various components for consistency. - Updated the LayoutDataProvider to handle workspace data more effectively. - Adjusted UI elements in IconRail and MobileSidebar to reflect the new terminology. - Modified messages in the localization file to align with the updated terminology. --- .../[workspace_id]/client-layout.tsx | 2 +- .../layout/providers/LayoutDataProvider.tsx | 25 +++++++++++++------ .../layout/ui/icon-rail/IconRail.tsx | 4 +-- .../layout/ui/sidebar/MobileSidebar.tsx | 2 +- surfsense_web/messages/en.json | 4 +-- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx index 0cd650a9a..3c4099fa3 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx @@ -214,7 +214,7 @@ export function DashboardClientLayout({ return ( - {children} + {children} ); } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 433d66353..0f79fb0bb 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -111,8 +111,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) : currentThreadState.id; - // Fetch current search space (for caching purposes) - useQuery({ + // Fetch current workspace as a fallback for the selector while the full list catches up. + const { data: currentWorkspace } = useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), enabled: !!searchSpaceId, @@ -267,11 +267,22 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid })); }, [searchSpacesData]); - // Find active search space from list (has is_owner and member_count) + // Find active workspace from list, falling back to the route-scoped detail query. const activeSearchSpace: SearchSpace | null = useMemo(() => { - if (!searchSpaceId || !searchSpaces.length) return null; - return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null; - }, [searchSpaceId, searchSpaces]); + if (!searchSpaceId) return null; + const searchSpaceIdNumber = Number(searchSpaceId); + const listedSpace = searchSpaces.find((s) => s.id === searchSpaceIdNumber); + if (listedSpace) return listedSpace; + if (!currentWorkspace || currentWorkspace.id !== searchSpaceIdNumber) return null; + return { + id: currentWorkspace.id, + name: currentWorkspace.name, + description: currentWorkspace.description, + isOwner: false, + memberCount: 0, + createdAt: currentWorkspace.created_at, + }; + }, [currentWorkspace, searchSpaceId, searchSpaces]); // Safety redirect: if the current search space is no longer in the user's list // (e.g. deleted by background task, membership revoked), redirect to a valid space. @@ -280,7 +291,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid return; if (searchSpaces.length > 0 && !activeSearchSpace) { router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); - } else if (searchSpaces.length === 0 && searchSpacesLoaded) { + } else if (searchSpaces.length === 0 && searchSpacesLoaded && !activeSearchSpace) { router.replace("/dashboard"); } }, [ diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx index a47b00e5c..05db59f11 100644 --- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -102,11 +102,11 @@ export function IconRail({ className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50" > - Add search space + Add workspace - Add search space + Add workspace diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 89a01d0c7..cb192ba37 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -133,7 +133,7 @@ export function MobileSidebar({ className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50" > - Add search space + Add workspace diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index df29de9de..ed994d9a6 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -682,9 +682,9 @@ "no_archived_chats": "No archived chats", "error_archiving_chat": "Failed to archive chat", "new_chat": "New chat", - "select_search_space": "Select Search Space", + "select_search_space": "Select Workspace", "manage_members": "Manage members", - "search_space_settings": "Search Space settings", + "search_space_settings": "Workspace settings", "logs": "Logs", "see_all_search_spaces": "See all search spaces", "expand_sidebar": "Expand sidebar", From 3719037c160a0ef1107130db7d66f358c42b7a44 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:16:21 +0530 Subject: [PATCH 04/26] refactor(workspace): standardize component props to use workspaceId - Updated component props across multiple files to replace searchSpaceId with workspaceId for consistency. - Adjusted internal logic to maintain functionality while aligning with the new terminology. --- .../components/builder/automation-builder-form.tsx | 5 +---- .../components/builder/mention-task-input.tsx | 5 ++++- .../components/delete-automation-dialog.tsx | 2 +- .../[workspace_id]/new-chat/[[...chat_id]]/page.tsx | 10 +++++----- .../dashboard/[workspace_id]/team/team-content.tsx | 12 ++++++------ surfsense_web/atoms/members/members-query.atoms.ts | 3 ++- .../public-chat-snapshots-manager.tsx | 4 ++-- .../components/settings/general-settings-manager.tsx | 5 +++-- .../settings/model-connections-settings.tsx | 3 ++- .../components/settings/prompt-config-manager.tsx | 5 +++-- surfsense_web/components/settings/roles-manager.tsx | 3 ++- .../tool-ui/automation/create-automation.tsx | 2 +- .../artifacts-library/ui/artifacts-library.tsx | 3 ++- 13 files changed, 34 insertions(+), 28 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx index e6fb96605..7d24c20e7 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx @@ -156,10 +156,7 @@ export function AutomationBuilderForm({ if (mode === "edit" && automation) { return { ...buildUpdatePayload(formForPayload), status: automation.status }; } - const { workspace_id: _ignored, ...rest } = buildCreatePayload( - formForPayload, - workspaceId - ); + const { search_space_id: _ignored, ...rest } = buildCreatePayload(formForPayload, workspaceId); return rest; } diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx index 614cfd7eb..1814af6b7 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx @@ -73,6 +73,9 @@ function toChipInput(mention: MentionedDocumentInfo): MentionChipInput { if (mention.kind === "folder") { return { id: mention.id, title: mention.title, kind: "folder" }; } + if (mention.kind === "thread") { + return { id: mention.id, title: mention.title, kind: "thread" }; + } return { id: mention.id, title: mention.title, @@ -232,7 +235,7 @@ export function MentionTaskInput({ => { const request: DeleteInviteRequest = { - workspace_id: workspaceId, + search_space_id: workspaceId, invite_id: inviteId, }; await revokeInvite(request); @@ -151,7 +151,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) { const handleCreateInvite = useCallback( async (inviteData: CreateInviteRequest["data"]) => { const request: CreateInviteRequest = { - workspace_id: workspaceId, + search_space_id: workspaceId, data: inviteData, }; return await createInvite(request); @@ -162,7 +162,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) { const handleUpdateMember = useCallback( async (membershipId: number, roleId: number | null): Promise => { const request: UpdateMembershipRequest = { - workspace_id: workspaceId, + search_space_id: workspaceId, membership_id: membershipId, data: { role_id: roleId }, }; @@ -174,7 +174,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) { const handleRemoveMember = useCallback( async (membershipId: number) => { const request: DeleteMembershipRequest = { - workspace_id: workspaceId, + search_space_id: workspaceId, membership_id: membershipId, }; await deleteMember(request); @@ -185,13 +185,13 @@ export function TeamContent({ workspaceId }: TeamContentProps) { const { data: roles = [], isLoading: rolesLoading } = useQuery({ queryKey: cacheKeys.roles.all(workspaceId.toString()), - queryFn: () => rolesApiService.getRoles({ workspace_id: workspaceId }), + queryFn: () => rolesApiService.getRoles({ search_space_id: workspaceId }), enabled: !!workspaceId, }); const { data: invites = [], isLoading: invitesLoading } = useQuery({ queryKey: cacheKeys.invites.all(workspaceId.toString()), - queryFn: () => invitesApiService.getInvites({ workspace_id: workspaceId }), + queryFn: () => invitesApiService.getInvites({ search_space_id: workspaceId }), staleTime: 5 * 60 * 1000, }); diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts index c1f507ff5..65ea4671d 100644 --- a/surfsense_web/atoms/members/members-query.atoms.ts +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -1,3 +1,4 @@ +import { useAtomValue } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { membersApiService } from "@/lib/apis/members-api.service"; @@ -71,6 +72,6 @@ export function canPerform( * const canManageMembers = usePermissionGate('manage_members'); */ export function usePermissionGate(permission: string): boolean { - const access = useAtomValue(myAccessAtom); + const { data: access } = useAtomValue(myAccessAtom); return canPerform(access, permission); } diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx index 2e759accb..5a53a8d87 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx @@ -14,11 +14,11 @@ import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.ty import { PublicChatSnapshotsList } from "./public-chat-snapshots-list"; interface PublicChatSnapshotsManagerProps { - searchSpaceId: number; + workspaceId: number; } export function PublicChatSnapshotsManager({ - searchSpaceId: _searchSpaceId, + workspaceId: _workspaceId, }: PublicChatSnapshotsManagerProps) { const [deletingId, setDeletingId] = useState(); diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx index d0c08d881..1f463147f 100644 --- a/surfsense_web/components/settings/general-settings-manager.tsx +++ b/surfsense_web/components/settings/general-settings-manager.tsx @@ -21,10 +21,11 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; import { Spinner } from "../ui/spinner"; interface GeneralSettingsManagerProps { - searchSpaceId: number; + workspaceId: number; } -export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) { +export function GeneralSettingsManager({ workspaceId }: GeneralSettingsManagerProps) { + const searchSpaceId = workspaceId; const t = useTranslations("searchSpaceSettings"); const tCommon = useTranslations("common"); const { diff --git a/surfsense_web/components/settings/model-connections-settings.tsx b/surfsense_web/components/settings/model-connections-settings.tsx index 3b30b1558..dc4b19a1a 100644 --- a/surfsense_web/components/settings/model-connections-settings.tsx +++ b/surfsense_web/components/settings/model-connections-settings.tsx @@ -50,7 +50,8 @@ function renderAutoModeOption() { ); } -export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: number }) { +export function ModelConnectionsSettings({ workspaceId }: { workspaceId: number }) { + const searchSpaceId = workspaceId; const [{ data: globalConnections = [] }] = useAtom(globalModelConnectionsAtom); const [{ data: connections = [] }] = useAtom(modelConnectionsAtom); const [{ data: roles }] = useAtom(modelRolesAtom); diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx index 997749a2a..132abf8a0 100644 --- a/surfsense_web/components/settings/prompt-config-manager.tsx +++ b/surfsense_web/components/settings/prompt-config-manager.tsx @@ -16,10 +16,11 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; import { Spinner } from "../ui/spinner"; interface PromptConfigManagerProps { - searchSpaceId: number; + workspaceId: number; } -export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) { +export function PromptConfigManager({ workspaceId }: PromptConfigManagerProps) { + const searchSpaceId = workspaceId; const { data: searchSpace, isLoading: loading } = useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), diff --git a/surfsense_web/components/settings/roles-manager.tsx b/surfsense_web/components/settings/roles-manager.tsx index 5c034470d..43a4dc658 100644 --- a/surfsense_web/components/settings/roles-manager.tsx +++ b/surfsense_web/components/settings/roles-manager.tsx @@ -269,7 +269,8 @@ type PermissionWithDescription = PermissionInfo; // ============ Roles Manager (for Settings page) ============ -export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) { +export function RolesManager({ workspaceId }: { workspaceId: number }) { + const searchSpaceId = workspaceId; const { data: access = null } = useAtomValue(myAccessAtom); const hasPermission = useCallback( diff --git a/surfsense_web/components/tool-ui/automation/create-automation.tsx b/surfsense_web/components/tool-ui/automation/create-automation.tsx index 92607beed..41c2419ff 100644 --- a/surfsense_web/components/tool-ui/automation/create-automation.tsx +++ b/surfsense_web/components/tool-ui/automation/create-automation.tsx @@ -284,7 +284,7 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {

Models

setModelSelection((prev) => ({ ...prev, ...patch }))} /> diff --git a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx index 3441f626e..2e264c8f5 100644 --- a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx +++ b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx @@ -60,7 +60,8 @@ function EmptyState() { ); } -export function ArtifactsLibrary({ searchSpaceId }: { searchSpaceId: number }) { +export function ArtifactsLibrary({ workspaceId }: { workspaceId: number }) { + const searchSpaceId = workspaceId; const { artifacts, loading, error, refresh } = useLibraryArtifacts(searchSpaceId); const openReportPanel = useSetAtom(openReportPanelAtom); const [selectedMedia, setSelectedMedia] = useState(null); From 248e3aae57927650489b133e8a0bce9be45fbcbe Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:17:13 +0530 Subject: [PATCH 05/26] fix(layout): update routing paths for user and search space settings --- .../components/layout/providers/LayoutDataProvider.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 0f79fb0bb..6775e9af0 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -399,7 +399,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom); const handleUserSettings = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/user-settings`); + router.push(`/dashboard/${searchSpaceId}/user-settings/profile`); }, [router, searchSpaceId]); const handleAnnouncements = useCallback(() => { @@ -408,7 +408,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const handleSearchSpaceSettings = useCallback( (space: SearchSpace) => { - router.push(`/dashboard/${space.id}/search-space-settings`); + router.push(`/dashboard/${space.id}/search-space-settings/general`); }, [router] ); @@ -606,7 +606,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid ); const handleSettings = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/search-space-settings`); + router.push(`/dashboard/${searchSpaceId}/search-space-settings/general`); }, [router, searchSpaceId]); const handleManageMembers = useCallback(() => { From 420ce3e153126ee5e3aa2dce44dcec976edbf93c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:26:32 +0530 Subject: [PATCH 06/26] feat(chats): implement ChatsPage component and update layout for all chats functionality --- .../dashboard/[workspace_id]/chats/page.tsx | 11 +++ .../layout/providers/LayoutDataProvider.tsx | 34 ++++++---- .../layout/ui/shell/LayoutShell.tsx | 48 ++----------- .../layout/ui/sidebar/AllChatsSidebar.tsx | 67 +++---------------- .../layout/ui/sidebar/MobileSidebar.tsx | 6 +- .../components/layout/ui/sidebar/Sidebar.tsx | 8 +-- .../components/layout/ui/sidebar/index.ts | 2 +- 7 files changed, 57 insertions(+), 119 deletions(-) create mode 100644 surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx diff --git a/surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx new file mode 100644 index 000000000..8186018ea --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx @@ -0,0 +1,11 @@ +import { AllChatsWorkspaceContent } from "@/components/layout/ui/sidebar"; + +export default async function ChatsPage({ params }: { params: Promise<{ workspace_id: string }> }) { + const { workspace_id } = await params; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 6775e9af0..f41e318d4 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -126,7 +126,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid }); // Unified slide-out panel state (only one can be open at a time) - type SlideoutPanel = "inbox" | "chats" | null; + type SlideoutPanel = "inbox" | null; const [activeSlideoutPanel, setActiveSlideoutPanel] = useState(null); const isInboxSidebarOpen = activeSlideoutPanel === "inbox"; @@ -631,10 +631,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid } }, [router]); - const handleViewAllChats = useCallback(() => { - setActiveSlideoutPanel((prev) => (prev === "chats" ? null : "chats")); - }, []); - // Delete handlers const confirmDeleteChat = useCallback(async () => { if (!chatToDelete) return; @@ -713,6 +709,13 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const isSearchSpaceSettingsPage = pathname?.includes("/search-space-settings") === true; const isTeamPage = pathname?.endsWith("/team") === true; const isAutomationsPage = pathname?.includes("/automations") === true; + const isAllChatsPage = pathname?.endsWith("/chats") === true; + const handleViewAllChats = useCallback(() => { + setActiveSlideoutPanel(null); + router.push( + isAllChatsPage ? `/dashboard/${searchSpaceId}/new-chat` : `/dashboard/${searchSpaceId}/chats` + ); + }, [isAllChatsPage, router, searchSpaceId]); const useWorkspacePanel = pathname?.endsWith("/buy-more") === true || pathname?.endsWith("/earn-credits") === true || @@ -720,7 +723,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage || - isAutomationsPage; + isAutomationsPage || + isAllChatsPage; return ( <> @@ -757,18 +761,25 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid theme={theme} setTheme={setTheme} isChatPage={isChatPage} + isAllChatsPage={isAllChatsPage} useWorkspacePanel={useWorkspacePanel} workspacePanelViewportClassName={ - isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage || isAutomationsPage + isUserSettingsPage || + isSearchSpaceSettingsPage || + isTeamPage || + isAutomationsPage || + isAllChatsPage ? "items-start justify-center px-6 py-8 md:px-10 md:py-10" : undefined } workspacePanelContentClassName={ isAutomationsPage ? "max-w-none select-none" - : isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage - ? "max-w-5xl" - : undefined + : isAllChatsPage + ? "max-w-3xl" + : isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage + ? "max-w-5xl" + : undefined } isLoadingChats={isLoadingThreads} activeSlideoutPanel={activeSlideoutPanel} @@ -797,9 +808,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid markAllAsRead: statusInbox.markAllAsRead, }, }} - allChatsPanel={{ - searchSpaceId, - }} documentsPanel={{ open: isDocumentsSidebarOpen, onOpenChange: setIsDocumentsSidebarOpen, diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 1c076a254..c437a3e3b 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -27,7 +27,6 @@ import { RightPanelToggleButton, } from "../right-panel/RightPanel"; import { - AllChatsSidebarContent, DocumentsSidebar, InboxSidebarContent, MobileSidebar, @@ -93,7 +92,7 @@ interface TabDataSource { markAllAsRead: () => Promise; } -export type ActiveSlideoutPanel = "inbox" | "chats" | null; +export type ActiveSlideoutPanel = "inbox" | null; // Inbox-related props — per-tab data sources with independent loading/pagination interface InboxProps { @@ -134,6 +133,7 @@ interface LayoutShellProps { setTheme?: (theme: "light" | "dark" | "system") => void; defaultCollapsed?: boolean; isChatPage?: boolean; + isAllChatsPage?: boolean; useWorkspacePanel?: boolean; workspacePanelViewportClassName?: string; workspacePanelContentClassName?: string; @@ -145,10 +145,6 @@ interface LayoutShellProps { // Inbox props inbox?: InboxProps; isLoadingChats?: boolean; - // All chats panel props - allChatsPanel?: { - searchSpaceId: string; - }; documentsPanel?: { open: boolean; onOpenChange: (open: boolean) => void; @@ -245,6 +241,7 @@ export function LayoutShell({ setTheme, defaultCollapsed = false, isChatPage = false, + isAllChatsPage = false, useWorkspacePanel = false, workspacePanelViewportClassName, workspacePanelContentClassName, @@ -254,7 +251,6 @@ export function LayoutShell({ onSlideoutPanelChange, inbox, isLoadingChats = false, - allChatsPanel, documentsPanel, onTabSwitch, onTabPrefetch, @@ -285,8 +281,7 @@ export function LayoutShell({ const anySlideOutOpen = activeSlideoutPanel !== null; - const panelAriaLabel = - activeSlideoutPanel === "inbox" ? "Inbox" : activeSlideoutPanel === "chats" ? "Chats" : "Panel"; + const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel"; // Mobile layout if (isMobile) { @@ -317,7 +312,7 @@ export function LayoutShell({ onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllChats={onViewAllChats} - isChatsPanelOpen={activeSlideoutPanel === "chats"} + isAllChatsActive={isAllChatsPage} user={user} onSettings={onSettings} onManageMembers={onManageMembers} @@ -369,22 +364,6 @@ export function LayoutShell({ /> )} - {activeSlideoutPanel === "chats" && allChatsPanel && ( - - closeSlideout(open)} - searchSpaceId={allChatsPanel.searchSpaceId} - onCloseMobileSidebar={() => setMobileMenuOpen(false)} - /> - - )} @@ -460,7 +439,7 @@ export function LayoutShell({ onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllChats={onViewAllChats} - isChatsPanelOpen={activeSlideoutPanel === "chats"} + isAllChatsActive={isAllChatsPage} user={user} onSettings={onSettings} onManageMembers={onManageMembers} @@ -526,21 +505,6 @@ export function LayoutShell({ /> )} - {activeSlideoutPanel === "chats" && allChatsPanel && ( - - closeSlideout(open)} - searchSpaceId={allChatsPanel.searchSpaceId} - /> - - )}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 3b6970513..0a15ecde5 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSetAtom } from "jotai"; import { ArchiveIcon, - ChevronLeft, MessageCircleMore, MoreHorizontal, Pencil, @@ -48,23 +47,13 @@ import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use- import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence"; import { formatThreadTimestamp } from "@/lib/format-date"; import { cn } from "@/lib/utils"; -import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; -export interface AllChatsSidebarContentProps { - onOpenChange: (open: boolean) => void; +interface AllChatsContentProps { searchSpaceId: string; - onCloseMobileSidebar?: () => void; + className?: string; } -interface AllChatsSidebarProps extends AllChatsSidebarContentProps { - open: boolean; -} - -export function AllChatsSidebarContent({ - onOpenChange, - searchSpaceId, - onCloseMobileSidebar, -}: AllChatsSidebarContentProps) { +function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { const t = useTranslations("sidebar"); const router = useRouter(); const params = useParams(); @@ -150,10 +139,8 @@ export function AllChatsSidebarContent({ searchSpaceId, visibility: thread.visibility, }); - onOpenChange(false); - onCloseMobileSidebar?.(); }, - [activateChatThread, onOpenChange, searchSpaceId, onCloseMobileSidebar] + [activateChatThread, searchSpaceId] ); const handleDeleteThread = useCallback( @@ -165,7 +152,6 @@ export function AllChatsSidebarContent({ toast.success(t("chat_deleted") || "Chat deleted successfully"); if (currentChatId === threadId) { - onOpenChange(false); setTimeout(() => { if ( fallbackTab?.type === "chat" && @@ -196,16 +182,7 @@ export function AllChatsSidebarContent({ setDeletingThreadId(null); } }, - [ - activateChatThread, - deleteThread, - t, - currentChatId, - router, - onOpenChange, - removeChatTab, - searchSpaceId, - ] + [activateChatThread, deleteThread, t, currentChatId, router, removeChatTab, searchSpaceId] ); const handleToggleArchive = useCallback( @@ -266,20 +243,9 @@ export function AllChatsSidebarContent({ const archivedCount = archivedChats.length; return ( - <> +
- {isMobile && ( - - )}

{t("chats") || "Chats"}

@@ -584,25 +550,14 @@ export function AllChatsSidebarContent({ - +
); } -export function AllChatsSidebar({ - open, - onOpenChange, - searchSpaceId, - onCloseMobileSidebar, -}: AllChatsSidebarProps) { - const t = useTranslations("sidebar"); - +export function AllChatsWorkspaceContent({ searchSpaceId }: { searchSpaceId: string }) { return ( - - - +
+ +
); } diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index cb192ba37..40a563ab2 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -27,7 +27,7 @@ interface MobileSidebarProps { onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllChats?: () => void; - isChatsPanelOpen?: boolean; + isAllChatsActive?: boolean; user: User; onSettings?: () => void; onManageMembers?: () => void; @@ -75,7 +75,7 @@ export function MobileSidebar({ onChatDelete, onChatArchive, onViewAllChats, - isChatsPanelOpen = false, + isAllChatsActive = false, user, onSettings, onManageMembers, @@ -166,7 +166,7 @@ export function MobileSidebar({ } : undefined } - isChatsPanelOpen={isChatsPanelOpen} + isAllChatsActive={isAllChatsActive} user={user} onSettings={ onSettings diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 6044508b0..6f17a95ce 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -75,7 +75,7 @@ interface SidebarProps { onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllChats?: () => void; - isChatsPanelOpen?: boolean; + isAllChatsActive?: boolean; user: User; onSettings?: () => void; onManageMembers?: () => void; @@ -112,7 +112,7 @@ export function Sidebar({ onChatDelete, onChatArchive, onViewAllChats, - isChatsPanelOpen = false, + isAllChatsActive = false, user, onSettings, onManageMembers, @@ -281,7 +281,7 @@ export function Sidebar({ title={t("recents")} defaultOpen={true} fillHeight={true} - alwaysShowAction={!disableTooltips && isChatsPanelOpen} + alwaysShowAction={!disableTooltips && isAllChatsActive} action={ onViewAllChats ? ( ) : undefined } diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index fcfe2252d..8a61633b8 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,4 +1,4 @@ -export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar"; +export { AllChatsWorkspaceContent } from "./AllChatsSidebar"; export { ChatListItem } from "./ChatListItem"; export { CreditBalanceDisplay } from "./CreditBalanceDisplay"; export { DocumentsSidebar } from "./DocumentsSidebar"; From 29d4fde2c0fe8deaad143a9f74481034af61d84e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:37:21 +0530 Subject: [PATCH 07/26] feat(chats): add filter dropdown for active and archived chats in AllChatsSidebar - Implemented a dropdown menu to filter between active and archived chats. - Removed the previous tabbed interface for filtering and replaced it with a more intuitive dropdown. - Updated layout and styling for better user experience. --- .../layout/ui/sidebar/AllChatsSidebar.tsx | 79 ++++++++++--------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 0a15ecde5..8e065f5a5 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -4,7 +4,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSetAtom } from "jotai"; import { ArchiveIcon, - MessageCircleMore, + Check, + ChevronDown, MoreHorizontal, Pencil, RotateCcwIcon, @@ -19,7 +20,6 @@ import { useTranslations } from "next-intl"; import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -239,14 +239,48 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; const error = isSearchMode ? searchError : threadsError; - const activeCount = activeChats.length; - const archivedCount = archivedChats.length; + const selectedFilterLabel = showArchived ? "Archived" : "Active"; return ( -
-
-
+
+
+

{t("chats") || "Chats"}

+ {!isSearchMode && ( + + + + + + setShowArchived(false)}> + Active + + + setShowArchived(true)}> + Archived + + + + + )}
@@ -272,35 +306,6 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) {
- {!isSearchMode && ( - setShowArchived(value === "archived")} - className="shrink-0 mx-3 mt-1.5" - > - - - - - Active - - {activeCount} - - - - - - - Archived - - {archivedCount} - - - - - - )} -
{isLoading ? (
@@ -556,7 +561,7 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { export function AllChatsWorkspaceContent({ searchSpaceId }: { searchSpaceId: string }) { return ( -
+
); From 865e22aaa4ce9210ebbf367efa13c9710df3c358 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:37:53 +0530 Subject: [PATCH 08/26] refactor(chats): simplify chat filtering logic in AllChatsSidebar --- .../layout/ui/sidebar/AllChatsSidebar.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 8e065f5a5..93bd51894 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -113,23 +113,15 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { enabled: !!searchSpaceId && isSearchMode, }); - const { activeChats, archivedChats } = useMemo(() => { + const threads = useMemo(() => { if (isSearchMode) { - return { - activeChats: (searchData ?? []).filter((t) => !t.archived), - archivedChats: (searchData ?? []).filter((t) => t.archived), - }; + return (searchData ?? []).filter((thread) => thread.archived === showArchived); } - if (!threadsData) return { activeChats: [], archivedChats: [] }; + if (!threadsData) return []; - return { - activeChats: threadsData.threads, - archivedChats: threadsData.archived_threads, - }; - }, [threadsData, searchData, isSearchMode]); - - const threads = showArchived ? archivedChats : activeChats; + return showArchived ? threadsData.archived_threads : threadsData.threads; + }, [threadsData, searchData, isSearchMode, showArchived]); const handleThreadClick = useCallback( (thread: ThreadListItem) => { From 72c2dd0e1b6cbf9d9bb9072b12c6cc9c3218d8b2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:40:49 +0530 Subject: [PATCH 09/26] refactor(team): streamline TeamPage function parameters and improve layout constraints - Simplified the TeamPage function parameter destructuring for better readability. - Updated LayoutDataProvider to increase maximum width for specific pages, enhancing layout consistency. - Adjusted ZeroProvider state management to use undefined instead of null for clarity in context handling. --- .../app/dashboard/[workspace_id]/team/page.tsx | 6 +----- .../layout/providers/LayoutDataProvider.tsx | 2 +- surfsense_web/components/providers/ZeroProvider.tsx | 11 +++++------ 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/team/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/team/page.tsx index 4f986b287..c81646e4c 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/team/page.tsx @@ -1,10 +1,6 @@ import { TeamContent } from "./team-content"; -export default async function TeamPage({ - params, -}: { - params: Promise<{ workspace_id: string }>; -}) { +export default async function TeamPage({ params }: { params: Promise<{ workspace_id: string }> }) { const { workspace_id } = await params; return ( diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index f41e318d4..2624384d3 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -776,7 +776,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid isAutomationsPage ? "max-w-none select-none" : isAllChatsPage - ? "max-w-3xl" + ? "max-w-5xl" : isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage ? "max-w-5xl" : undefined diff --git a/surfsense_web/components/providers/ZeroProvider.tsx b/surfsense_web/components/providers/ZeroProvider.tsx index cfb8fe586..e29effda8 100644 --- a/surfsense_web/components/providers/ZeroProvider.tsx +++ b/surfsense_web/components/providers/ZeroProvider.tsx @@ -21,6 +21,7 @@ type LoadedZeroContext = { context: ZeroContext; desktopAuth?: string; }; +type ZeroContextState = LoadedZeroContext | null | undefined; function getCacheURL() { if (configuredCacheURL) return configuredCacheURL; @@ -131,7 +132,7 @@ function AuthenticatedZeroProvider({ children: React.ReactNode; isDesktop: boolean; }) { - const [loadedContext, setLoadedContext] = useState(null); + const [loadedContext, setLoadedContext] = useState(undefined); useEffect(() => { let isMounted = true; @@ -153,7 +154,7 @@ function AuthenticatedZeroProvider({ const unsubscribe = window.electronAPI.onAuthChanged(({ accessToken }) => { if (!accessToken) { - setLoadedContext(null); + setLoadedContext(undefined); return; } void load(); @@ -165,9 +166,7 @@ function AuthenticatedZeroProvider({ }; }, [isDesktop]); - if (!loadedContext) { - return <>{children}; - } + if (!loadedContext) return null; return ( {children}; + return null; } return {children}; From cda232101fad496723259efaf1043c0387ed15a6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:50:20 +0530 Subject: [PATCH 10/26] feat(workspace): remove AI file sorting feature and related components --- README.md | 2 - .../versions/172_remove_ai_file_sort.py | 31 ++ surfsense_backend/app/db.py | 4 - .../events/document_entered_folder.py | 2 +- .../indexing_pipeline_service.py | 25 -- .../app/routes/workspaces_routes.py | 38 -- surfsense_backend/app/schemas/workspace.py | 2 - .../app/services/ai_file_sort_service.py | 329 ------------------ .../app/tasks/celery_tasks/document_tasks.py | 106 ------ .../tests/unit/observability/test_helpers.py | 1 - .../services/test_ai_file_sort_service.py | 275 --------------- .../unit/services/test_ai_sort_task_dedupe.py | 43 --- .../unit/services/test_folder_hierarchy.py | 4 +- .../components/documents/DocumentsFilters.tsx | 51 +-- .../components/documents/FolderTreeView.tsx | 56 +-- .../layout/ui/sidebar/DocumentsSidebar.tsx | 63 ---- .../contracts/types/search-space.types.ts | 2 - .../lib/apis/search-spaces-api.service.ts | 11 - 18 files changed, 40 insertions(+), 1005 deletions(-) create mode 100644 surfsense_backend/alembic/versions/172_remove_ai_file_sort.py delete mode 100644 surfsense_backend/app/services/ai_file_sort_service.py delete mode 100644 surfsense_backend/tests/unit/services/test_ai_file_sort_service.py delete mode 100644 surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py diff --git a/README.md b/README.md index a75122892..d116ef64d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ NotebookLM is one of the best and most useful AI platforms out there, but once y - **No Vendor Lock-in** - Configure any LLM, image, TTS, and STT models to use. - **25+ External Data Sources** - Add your sources from Google Drive, OneDrive, Dropbox, Notion, and many other external services. - **Real-Time Multiplayer Support** - Work easily with your team members in a shared notebook. -- **AI File Sorting** - Automatically organize your documents into a smart folder hierarchy using AI-powered categorization by source, date, and topic. - **AI Automations & Agents** - Run AI agents on a schedule or trigger them the moment a document lands in a folder, then write results back to Notion, Slack, Linear, and Drive. Build no-code automations just by describing them in chat. - **Desktop App** - Get AI assistance in any application with Quick Assist, General Assist, Screenshot Assist, and local folder sync. @@ -271,7 +270,6 @@ All features operate against your chosen search space, so your answers are alway | **Video Generation** | Cinematic Video Overviews via Veo 3 (Ultra only) | Available (NotebookLM is better here, actively improving) | | **Presentation Generation** | Better looking slides but not editable | Create editable, slide-based presentations | | **Podcast Generation** | Audio Overviews with customizable hosts and languages | Available with multiple TTS providers (NotebookLM is better here, actively improving) | -| **AI File Sorting** | No | LLM-powered auto-categorization into source, date, category, and subcategory folders | | **AI Automations & Agents** | No | Scheduled AI workflows, event triggers on new documents, and chat-built no-code automations with connector write-back to Notion, Slack, Linear & Jira | | **Desktop App** | No | Native app with General Assist, Quick Assist, Screenshot Assist, and local folder sync | | **Browser Extension** | No | Cross-browser extension to save any webpage, including auth-protected pages | diff --git a/surfsense_backend/alembic/versions/172_remove_ai_file_sort.py b/surfsense_backend/alembic/versions/172_remove_ai_file_sort.py new file mode 100644 index 000000000..6db2d5c8a --- /dev/null +++ b/surfsense_backend/alembic/versions/172_remove_ai_file_sort.py @@ -0,0 +1,31 @@ +"""Remove AI file sort flag. + +Revision ID: 172 +Revises: 171 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +revision: str = "172" +down_revision: str | None = "171" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_column("workspaces", "ai_file_sort_enabled") + + +def downgrade() -> None: + op.add_column( + "workspaces", + sa.Column( + "ai_file_sort_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index dd13b1274..3cfdb5f24 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1732,10 +1732,6 @@ class Workspace(BaseModel, TimestampMixin): Integer, nullable=True, default=0, server_default="0" ) # For vision/screenshot analysis, defaults to Auto mode - ai_file_sort_enabled = Column( - Boolean, nullable=False, default=False, server_default="false" - ) - user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) diff --git a/surfsense_backend/app/event_bus/events/document_entered_folder.py b/surfsense_backend/app/event_bus/events/document_entered_folder.py index 66560e9e5..d6510eb84 100644 --- a/surfsense_backend/app/event_bus/events/document_entered_folder.py +++ b/surfsense_backend/app/event_bus/events/document_entered_folder.py @@ -1,6 +1,6 @@ """``document.entered_folder``: a document became a member of a folder. -Fires once per arrival, however the document got there (upload, AI sort, move). +Fires once per arrival, however the document got there (upload or move). The payload carries the fields a user can filter a trigger on. """ diff --git a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py index a9f2dd757..539072cf0 100644 --- a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py +++ b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py @@ -427,8 +427,6 @@ class IndexingPipelineService: log_index_success(ctx, chunk_count=chunk_count) outcome_status = "success" - await self._enqueue_ai_sort_if_enabled(document) - except RETRYABLE_LLM_ERRORS as e: ot.record_error(persist_span, e) log_retryable_llm_error(ctx, e) @@ -564,29 +562,6 @@ class IndexingPipelineService: ) return len(new_texts) - async def _enqueue_ai_sort_if_enabled(self, document: Document) -> None: - """Fire-and-forget: enqueue incremental AI sort if the workspace has it enabled.""" - try: - from app.db import Workspace - - result = await self.session.execute( - select(Workspace.ai_file_sort_enabled).where( - Workspace.id == document.workspace_id - ) - ) - enabled = result.scalar() - if not enabled: - return - - from app.tasks.celery_tasks.document_tasks import ai_sort_document_task - - user_id = str(document.created_by_id) if document.created_by_id else "" - ai_sort_document_task.delay(document.workspace_id, user_id, document.id) - except Exception: - logging.getLogger(__name__).warning( - "Failed to enqueue AI sort for document %s", document.id, exc_info=True - ) - async def index_batch_parallel( self, connector_docs: list[ConnectorDocument], diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py index 55eaa8ea9..4f36ef62b 100644 --- a/surfsense_backend/app/routes/workspaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -189,7 +189,6 @@ async def read_workspaces( citations_enabled=space.citations_enabled, api_access_enabled=space.api_access_enabled, qna_custom_instructions=space.qna_custom_instructions, - ai_file_sort_enabled=space.ai_file_sort_enabled, member_count=member_count, is_owner=is_owner, ) @@ -326,43 +325,6 @@ async def update_workspace_api_access( ) from e -@router.post("/workspaces/{workspace_id}/ai-sort") -async def trigger_ai_sort( - workspace_id: int, - session: AsyncSession = Depends(get_async_session), - auth: AuthContext = Depends(get_auth_context), -): - user = auth.user - """Trigger a full AI file sort for all documents in the workspace.""" - try: - await check_permission( - session, - auth, - workspace_id, - Permission.SETTINGS_UPDATE.value, - "You don't have permission to trigger AI sort on this workspace", - ) - - result = await session.execute( - select(Workspace).filter(Workspace.id == workspace_id) - ) - db_workspace = result.scalars().first() - if not db_workspace: - raise HTTPException(status_code=404, detail="Workspace not found") - - from app.tasks.celery_tasks.document_tasks import ai_sort_workspace_task - - ai_sort_workspace_task.delay(workspace_id, str(user.id)) - return {"message": "AI sort started"} - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to trigger AI sort: {e!s}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Failed to trigger AI sort: {e!s}" - ) from e - - @router.delete("/workspaces/{workspace_id}", response_model=dict) async def delete_workspace( workspace_id: int, diff --git a/surfsense_backend/app/schemas/workspace.py b/surfsense_backend/app/schemas/workspace.py index 15c754e81..57b7e0a52 100644 --- a/surfsense_backend/app/schemas/workspace.py +++ b/surfsense_backend/app/schemas/workspace.py @@ -21,7 +21,6 @@ class WorkspaceUpdate(BaseModel): description: str | None = None citations_enabled: bool | None = None qna_custom_instructions: str | None = None - ai_file_sort_enabled: bool | None = None class WorkspaceApiAccessUpdate(BaseModel): @@ -36,7 +35,6 @@ class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel): api_access_enabled: bool = False qna_custom_instructions: str | None = None shared_memory_md: str | None = None - ai_file_sort_enabled: bool = False model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_backend/app/services/ai_file_sort_service.py b/surfsense_backend/app/services/ai_file_sort_service.py deleted file mode 100644 index cc4727608..000000000 --- a/surfsense_backend/app/services/ai_file_sort_service.py +++ /dev/null @@ -1,329 +0,0 @@ -"""AI File Sort Service: builds connector-type/date/category/subcategory folder paths.""" - -from __future__ import annotations - -import json -import logging -import re -from datetime import UTC, datetime - -from langchain_core.messages import HumanMessage -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy.orm import selectinload - -from app.db import ( - Chunk, - Document, - DocumentType, - SearchSourceConnector, - SearchSourceConnectorType, -) -from app.services.folder_service import ensure_folder_hierarchy_with_depth_validation - -logger = logging.getLogger(__name__) - -_DOCTYPE_TO_CONNECTOR_LABEL: dict[str, str] = { - DocumentType.EXTENSION: "Browser Extension", - DocumentType.CRAWLED_URL: "Web Crawl", - DocumentType.FILE: "File Upload", - DocumentType.SLACK_CONNECTOR: "Slack", - DocumentType.TEAMS_CONNECTOR: "Teams", - DocumentType.ONEDRIVE_FILE: "OneDrive", - DocumentType.NOTION_CONNECTOR: "Notion", - DocumentType.YOUTUBE_VIDEO: "YouTube", - DocumentType.GITHUB_CONNECTOR: "GitHub", - DocumentType.LINEAR_CONNECTOR: "Linear", - DocumentType.DISCORD_CONNECTOR: "Discord", - DocumentType.JIRA_CONNECTOR: "Jira", - DocumentType.CONFLUENCE_CONNECTOR: "Confluence", - DocumentType.CLICKUP_CONNECTOR: "ClickUp", - DocumentType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", - DocumentType.GOOGLE_GMAIL_CONNECTOR: "Gmail", - DocumentType.GOOGLE_DRIVE_FILE: "Google Drive", - DocumentType.AIRTABLE_CONNECTOR: "Airtable", - DocumentType.LUMA_CONNECTOR: "Luma", - DocumentType.ELASTICSEARCH_CONNECTOR: "Elasticsearch", - DocumentType.BOOKSTACK_CONNECTOR: "BookStack", - DocumentType.CIRCLEBACK: "Circleback", - DocumentType.OBSIDIAN_CONNECTOR: "Obsidian", - DocumentType.NOTE: "Notes", - DocumentType.DROPBOX_FILE: "Dropbox", - DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)", - DocumentType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)", - DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)", - DocumentType.LOCAL_FOLDER_FILE: "Local Folder", -} - -_CONNECTOR_TYPE_LABEL: dict[str, str] = { - SearchSourceConnectorType.SERPER_API: "Serper Search", - SearchSourceConnectorType.TAVILY_API: "Tavily Search", - SearchSourceConnectorType.SEARXNG_API: "SearXNG Search", - SearchSourceConnectorType.LINKUP_API: "Linkup Search", - SearchSourceConnectorType.BAIDU_SEARCH_API: "Baidu Search", - SearchSourceConnectorType.SLACK_CONNECTOR: "Slack", - SearchSourceConnectorType.TEAMS_CONNECTOR: "Teams", - SearchSourceConnectorType.ONEDRIVE_CONNECTOR: "OneDrive", - SearchSourceConnectorType.NOTION_CONNECTOR: "Notion", - SearchSourceConnectorType.GITHUB_CONNECTOR: "GitHub", - SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear", - SearchSourceConnectorType.DISCORD_CONNECTOR: "Discord", - SearchSourceConnectorType.JIRA_CONNECTOR: "Jira", - SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence", - SearchSourceConnectorType.CLICKUP_CONNECTOR: "ClickUp", - SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", - SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: "Gmail", - SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive", - SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable", - SearchSourceConnectorType.LUMA_CONNECTOR: "Luma", - SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: "Elasticsearch", - SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: "Web Crawl", - SearchSourceConnectorType.BOOKSTACK_CONNECTOR: "BookStack", - SearchSourceConnectorType.CIRCLEBACK_CONNECTOR: "Circleback", - SearchSourceConnectorType.OBSIDIAN_CONNECTOR: "Obsidian", - SearchSourceConnectorType.MCP_CONNECTOR: "MCP", - SearchSourceConnectorType.DROPBOX_CONNECTOR: "Dropbox", - SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)", - SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)", - SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)", -} - -_MAX_CONTENT_CHARS = 4000 -_MAX_CHUNKS_FOR_CONTEXT = 5 - -_CATEGORY_PROMPT = ( - "Based on the document information below, classify it into a broad category " - "and a more specific subcategory.\n\n" - "Rules:\n" - "- category: 1-2 word broad theme (e.g. Science, Finance, Engineering, Communication, Media)\n" - "- subcategory: 1-2 word specific topic within the category " - "(e.g. Physics, Tax Reports, Backend, Team Updates)\n" - "- Use nouns only. Do not include generic terms like 'General' or 'Miscellaneous'.\n\n" - "Title: {title}\n\n" - "Content: {summary}\n\n" - 'Respond with ONLY a JSON object: {{"category": "...", "subcategory": "..."}}' -) - -_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9 _\-()]") -_FALLBACK_CATEGORY = "Uncategorized" -_FALLBACK_SUBCATEGORY = "General" - - -def resolve_root_folder_label( - document: Document, connector: SearchSourceConnector | None -) -> str: - if connector is not None: - return _CONNECTOR_TYPE_LABEL.get( - connector.connector_type, str(connector.connector_type) - ) - return _DOCTYPE_TO_CONNECTOR_LABEL.get( - document.document_type, str(document.document_type) - ) - - -def resolve_date_folder(document: Document) -> str: - ts = document.updated_at or document.created_at - if ts is None: - ts = datetime.now(UTC) - return ts.strftime("%Y-%m-%d") - - -def sanitize_category_folder_name( - value: str | None, fallback: str = _FALLBACK_CATEGORY -) -> str: - if not value or not value.strip(): - return fallback - cleaned = _SAFE_NAME_RE.sub("", value.strip()) - cleaned = " ".join(cleaned.split()) - if not cleaned: - return fallback - return cleaned[:50] - - -async def _resolve_document_text( - session: AsyncSession, - document: Document, -) -> str: - """Build the best available text representation for taxonomy generation. - - Prefers ``document.content``; falls back to joining the first few chunks - when content is empty or too short to be useful. - """ - text = (document.content or "").strip() - if len(text) >= 100: - return text[:_MAX_CONTENT_CHARS] - - stmt = ( - select(Chunk.content) - .where(Chunk.document_id == document.id) - .order_by(Chunk.position, Chunk.id) - .limit(_MAX_CHUNKS_FOR_CONTEXT) - ) - result = await session.execute(stmt) - chunk_texts = [row[0] for row in result.all() if row[0]] - if chunk_texts: - combined = "\n\n".join(chunk_texts) - return combined[:_MAX_CONTENT_CHARS] - - return text[:_MAX_CONTENT_CHARS] - - -def _get_cached_taxonomy(document: Document) -> tuple[str, str] | None: - """Return (category, subcategory) from document metadata cache, or None.""" - meta = document.document_metadata - if not isinstance(meta, dict): - return None - cat = meta.get("ai_sort_category") - subcat = meta.get("ai_sort_subcategory") - if cat and subcat and isinstance(cat, str) and isinstance(subcat, str): - return cat, subcat - return None - - -def _set_cached_taxonomy(document: Document, category: str, subcategory: str) -> None: - """Persist the AI taxonomy on document metadata for deterministic re-sorts.""" - meta = dict(document.document_metadata or {}) - meta["ai_sort_category"] = category - meta["ai_sort_subcategory"] = subcategory - document.document_metadata = meta - - -async def generate_ai_taxonomy( - title: str, - summary_or_content: str, - llm, -) -> tuple[str, str]: - """Return (category, subcategory) using a single structured LLM call.""" - text = (summary_or_content or "").strip() - if not text: - return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY - - if len(text) > _MAX_CONTENT_CHARS: - text = text[:_MAX_CONTENT_CHARS] - - prompt = _CATEGORY_PROMPT.format(title=title or "Untitled", summary=text) - try: - result = await llm.ainvoke([HumanMessage(content=prompt)]) - raw = result.content.strip() - if raw.startswith("```"): - raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - parsed = json.loads(raw) - category = sanitize_category_folder_name( - parsed.get("category"), _FALLBACK_CATEGORY - ) - subcategory = sanitize_category_folder_name( - parsed.get("subcategory"), _FALLBACK_SUBCATEGORY - ) - return category, subcategory - except Exception: - logger.warning("AI taxonomy generation failed, using fallback", exc_info=True) - return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY - - -def _build_path_segments( - root_label: str, - date_label: str, - category: str, - subcategory: str, -) -> list[dict]: - return [ - {"name": root_label, "metadata": {"ai_sort": True, "ai_sort_level": 1}}, - {"name": date_label, "metadata": {"ai_sort": True, "ai_sort_level": 2}}, - {"name": category, "metadata": {"ai_sort": True, "ai_sort_level": 3}}, - {"name": subcategory, "metadata": {"ai_sort": True, "ai_sort_level": 4}}, - ] - - -async def _resolve_taxonomy( - session: AsyncSession, - document: Document, - llm, -) -> tuple[str, str]: - """Return (category, subcategory), reusing cached values when available.""" - cached = _get_cached_taxonomy(document) - if cached is not None: - return cached - - content_text = await _resolve_document_text(session, document) - category, subcategory = await generate_ai_taxonomy( - document.title, content_text, llm - ) - _set_cached_taxonomy(document, category, subcategory) - return category, subcategory - - -async def ai_sort_document( - session: AsyncSession, - document: Document, - llm, -) -> Document: - """Sort a single document into the 4-level AI folder hierarchy.""" - connector: SearchSourceConnector | None = None - if document.connector_id is not None: - connector = await session.get(SearchSourceConnector, document.connector_id) - - root_label = resolve_root_folder_label(document, connector) - date_label = resolve_date_folder(document) - - category, subcategory = await _resolve_taxonomy(session, document, llm) - - segments = _build_path_segments(root_label, date_label, category, subcategory) - - leaf_folder = await ensure_folder_hierarchy_with_depth_validation( - session, - document.workspace_id, - segments, - ) - - document.folder_id = leaf_folder.id - await session.flush() - return document - - -async def ai_sort_all_documents( - session: AsyncSession, - workspace_id: int, - llm, -) -> tuple[int, int]: - """Sort all documents in a workspace. Returns (sorted_count, failed_count).""" - stmt = ( - select(Document) - .where(Document.workspace_id == workspace_id) - .options(selectinload(Document.connector)) - ) - result = await session.execute(stmt) - documents = list(result.scalars().all()) - - sorted_count = 0 - failed_count = 0 - - for doc in documents: - try: - connector = doc.connector - root_label = resolve_root_folder_label(doc, connector) - date_label = resolve_date_folder(doc) - - category, subcategory = await _resolve_taxonomy(session, doc, llm) - segments = _build_path_segments( - root_label, date_label, category, subcategory - ) - - leaf_folder = await ensure_folder_hierarchy_with_depth_validation( - session, - workspace_id, - segments, - ) - doc.folder_id = leaf_folder.id - sorted_count += 1 - except Exception: - logger.error("Failed to AI-sort document %s", doc.id, exc_info=True) - failed_count += 1 - - await session.commit() - logger.info( - "AI sort complete for workspace=%d: sorted=%d, failed=%d", - workspace_id, - sorted_count, - failed_count, - ) - return sorted_count, failed_count diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py index c936b0080..3e26050ef 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py @@ -4,7 +4,6 @@ import asyncio import contextlib import logging import os -import time from uuid import UUID from app.celery_app import celery_app @@ -1395,108 +1394,3 @@ async def _index_uploaded_folder_files_async( if notification_id is not None: _stop_heartbeat(notification_id) - -# ===== AI File Sort tasks ===== - -AI_SORT_LOCK_TTL_SECONDS = 600 # 10 minutes -_ai_sort_redis = None - - -def _get_ai_sort_redis(): - import redis - - global _ai_sort_redis - if _ai_sort_redis is None: - _ai_sort_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True) - return _ai_sort_redis - - -def _ai_sort_lock_key(workspace_id: int) -> str: - return f"ai_sort:workspace:{workspace_id}:lock" - - -@celery_app.task(name="ai_sort_search_space", bind=True, max_retries=1) -def ai_sort_workspace_task(self, workspace_id: int, user_id: str): - """Full AI sort for all documents in a workspace.""" - return run_async_celery_task( - lambda: _ai_sort_workspace_async(workspace_id, user_id) - ) - - -async def _ai_sort_workspace_async(workspace_id: int, user_id: str): - r = _get_ai_sort_redis() - lock_key = _ai_sort_lock_key(workspace_id) - - if not r.set(lock_key, "running", nx=True, ex=AI_SORT_LOCK_TTL_SECONDS): - logger.info( - "AI sort already running for workspace=%d, skipping", - workspace_id, - ) - return - - t_start = time.perf_counter() - try: - from app.services.ai_file_sort_service import ai_sort_all_documents - from app.services.llm_service import get_agent_llm - - async with get_celery_session_maker()() as session: - llm = await get_agent_llm(session, workspace_id, disable_streaming=True) - if llm is None: - logger.warning( - "No LLM configured for workspace=%d, skipping AI sort", - workspace_id, - ) - return - - sorted_count, failed_count = await ai_sort_all_documents( - session, workspace_id, llm - ) - elapsed = time.perf_counter() - t_start - logger.info( - "AI sort workspace=%d done in %.1fs: sorted=%d failed=%d", - workspace_id, - elapsed, - sorted_count, - failed_count, - ) - finally: - r.delete(lock_key) - - -@celery_app.task( - name="ai_sort_document", bind=True, max_retries=2, default_retry_delay=10 -) -def ai_sort_document_task(self, workspace_id: int, user_id: str, document_id: int): - """Incremental AI sort for a single document after indexing.""" - return run_async_celery_task( - lambda: _ai_sort_document_async(workspace_id, user_id, document_id) - ) - - -async def _ai_sort_document_async(workspace_id: int, user_id: str, document_id: int): - from app.db import Document - from app.services.ai_file_sort_service import ai_sort_document - from app.services.llm_service import get_agent_llm - - async with get_celery_session_maker()() as session: - document = await session.get(Document, document_id) - if document is None: - logger.warning("Document %d not found, skipping AI sort", document_id) - return - - llm = await get_agent_llm(session, workspace_id, disable_streaming=True) - if llm is None: - logger.warning( - "No LLM for workspace=%d, skipping AI sort of doc=%d", - workspace_id, - document_id, - ) - return - - await ai_sort_document(session, document, llm) - await session.commit() - logger.info( - "AI sorted document=%d into workspace=%d", - document_id, - workspace_id, - ) diff --git a/surfsense_backend/tests/unit/observability/test_helpers.py b/surfsense_backend/tests/unit/observability/test_helpers.py index d81a7ed50..00e51e467 100644 --- a/surfsense_backend/tests/unit/observability/test_helpers.py +++ b/surfsense_backend/tests/unit/observability/test_helpers.py @@ -35,7 +35,6 @@ def _disable_otel(monkeypatch: pytest.MonkeyPatch): ("cleanup_stale_indexing_notifications", "cleanup"), ("reconcile_pending_stripe_credit_purchases", "reconcile"), ("check_periodic_schedules", "check"), - ("ai_sort_search_space", "ai"), ("index_notion_pages", "index"), ("index_github_repos", "index"), ("index_google_drive_files", "index"), diff --git a/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py b/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py deleted file mode 100644 index 860c2ffa2..000000000 --- a/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Unit tests for AI file sort service: folder label resolution, date extraction, category sanitization.""" - -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest - -pytestmark = pytest.mark.unit - - -# ── resolve_root_folder_label ── - - -def _make_document(document_type: str, connector_id=None): - doc = MagicMock() - doc.document_type = document_type - doc.connector_id = connector_id - return doc - - -def _make_connector(connector_type: str): - conn = MagicMock() - conn.connector_type = connector_type - return conn - - -def test_root_label_uses_connector_type_when_available(): - from app.services.ai_file_sort_service import resolve_root_folder_label - - doc = _make_document("FILE", connector_id=1) - conn = _make_connector("GOOGLE_DRIVE_CONNECTOR") - assert resolve_root_folder_label(doc, conn) == "Google Drive" - - -def test_root_label_falls_back_to_document_type(): - from app.services.ai_file_sort_service import resolve_root_folder_label - - doc = _make_document("SLACK_CONNECTOR") - assert resolve_root_folder_label(doc, None) == "Slack" - - -def test_root_label_unknown_doctype_returns_raw_value(): - from app.services.ai_file_sort_service import resolve_root_folder_label - - doc = _make_document("UNKNOWN_TYPE") - assert resolve_root_folder_label(doc, None) == "UNKNOWN_TYPE" - - -# ── resolve_date_folder ── - - -def test_date_folder_from_updated_at(): - from app.services.ai_file_sort_service import resolve_date_folder - - doc = MagicMock() - doc.updated_at = datetime(2025, 3, 15, 10, 30, 0, tzinfo=UTC) - doc.created_at = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) - assert resolve_date_folder(doc) == "2025-03-15" - - -def test_date_folder_falls_back_to_created_at(): - from app.services.ai_file_sort_service import resolve_date_folder - - doc = MagicMock() - doc.updated_at = None - doc.created_at = datetime(2024, 12, 25, 23, 59, 0, tzinfo=UTC) - assert resolve_date_folder(doc) == "2024-12-25" - - -def test_date_folder_both_none_uses_today(): - from app.services.ai_file_sort_service import resolve_date_folder - - doc = MagicMock() - doc.updated_at = None - doc.created_at = None - result = resolve_date_folder(doc) - today = datetime.now(UTC).strftime("%Y-%m-%d") - assert result == today - - -# ── sanitize_category_folder_name ── - - -def test_sanitize_normal_value(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - assert sanitize_category_folder_name("Machine Learning") == "Machine Learning" - - -def test_sanitize_strips_special_chars(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - assert sanitize_category_folder_name("Tax/Reports!") == "TaxReports" - - -def test_sanitize_empty_returns_fallback(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - assert sanitize_category_folder_name("") == "Uncategorized" - assert sanitize_category_folder_name(None) == "Uncategorized" - - -def test_sanitize_truncates_long_names(): - from app.services.ai_file_sort_service import sanitize_category_folder_name - - long_name = "A" * 100 - result = sanitize_category_folder_name(long_name) - assert len(result) <= 50 - - -# ── generate_ai_taxonomy ── - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_parses_json(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = '{"category": "Science", "subcategory": "Physics"}' - mock_llm.ainvoke.return_value = mock_result - - cat, sub = await generate_ai_taxonomy( - "Physics Paper", "Some science document about physics", mock_llm - ) - assert cat == "Science" - assert sub == "Physics" - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_handles_markdown_code_block(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = ( - '```json\n{"category": "Finance", "subcategory": "Tax Reports"}\n```' - ) - mock_llm.ainvoke.return_value = mock_result - - cat, sub = await generate_ai_taxonomy("Tax Doc", "A tax report document", mock_llm) - assert cat == "Finance" - assert sub == "Tax Reports" - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_includes_title_in_prompt(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = '{"category": "Engineering", "subcategory": "Backend"}' - mock_llm.ainvoke.return_value = mock_result - - await generate_ai_taxonomy("API Design Guide", "content about REST APIs", mock_llm) - - prompt_text = mock_llm.ainvoke.call_args[0][0][0].content - assert "API Design Guide" in prompt_text - assert "content about REST APIs" in prompt_text - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_fallback_on_error(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_llm.ainvoke.side_effect = RuntimeError("LLM down") - - cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm) - assert cat == "Uncategorized" - assert sub == "General" - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_fallback_on_empty_content(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - cat, sub = await generate_ai_taxonomy("Title", "", mock_llm) - assert cat == "Uncategorized" - assert sub == "General" - mock_llm.ainvoke.assert_not_called() - - -@pytest.mark.asyncio -async def test_generate_ai_taxonomy_fallback_on_invalid_json(): - from app.services.ai_file_sort_service import generate_ai_taxonomy - - mock_llm = AsyncMock() - mock_result = MagicMock() - mock_result.content = "not valid json at all" - mock_llm.ainvoke.return_value = mock_result - - cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm) - assert cat == "Uncategorized" - assert sub == "General" - - -# ── taxonomy caching ── - - -def test_get_cached_taxonomy_returns_none_when_no_metadata(): - from app.services.ai_file_sort_service import _get_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = None - assert _get_cached_taxonomy(doc) is None - - -def test_get_cached_taxonomy_returns_none_when_keys_missing(): - from app.services.ai_file_sort_service import _get_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = {"some_other_key": "value"} - assert _get_cached_taxonomy(doc) is None - - -def test_get_cached_taxonomy_returns_cached_values(): - from app.services.ai_file_sort_service import _get_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = { - "ai_sort_category": "Finance", - "ai_sort_subcategory": "Tax Reports", - } - assert _get_cached_taxonomy(doc) == ("Finance", "Tax Reports") - - -def test_set_cached_taxonomy_persists_on_metadata(): - from app.services.ai_file_sort_service import _set_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = {"existing_key": "keep_me"} - _set_cached_taxonomy(doc, "Science", "Physics") - assert doc.document_metadata["ai_sort_category"] == "Science" - assert doc.document_metadata["ai_sort_subcategory"] == "Physics" - assert doc.document_metadata["existing_key"] == "keep_me" - - -def test_set_cached_taxonomy_creates_metadata_when_none(): - from app.services.ai_file_sort_service import _set_cached_taxonomy - - doc = MagicMock() - doc.document_metadata = None - _set_cached_taxonomy(doc, "Engineering", "Backend") - assert doc.document_metadata == { - "ai_sort_category": "Engineering", - "ai_sort_subcategory": "Backend", - } - - -# ── _build_path_segments ── - - -def test_build_path_segments_structure(): - from app.services.ai_file_sort_service import _build_path_segments - - segments = _build_path_segments("Google Drive", "2025-03-15", "Science", "Physics") - assert len(segments) == 4 - assert segments[0] == { - "name": "Google Drive", - "metadata": {"ai_sort": True, "ai_sort_level": 1}, - } - assert segments[1] == { - "name": "2025-03-15", - "metadata": {"ai_sort": True, "ai_sort_level": 2}, - } - assert segments[2] == { - "name": "Science", - "metadata": {"ai_sort": True, "ai_sort_level": 3}, - } - assert segments[3] == { - "name": "Physics", - "metadata": {"ai_sort": True, "ai_sort_level": 4}, - } diff --git a/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py b/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py deleted file mode 100644 index 2acfde1ad..000000000 --- a/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Unit tests for AI sort task Redis deduplication lock.""" - -from unittest.mock import MagicMock, patch - -import pytest - -pytestmark = pytest.mark.unit - - -def test_lock_key_format(): - from app.tasks.celery_tasks.document_tasks import _ai_sort_lock_key - - key = _ai_sort_lock_key(42) - assert key == "ai_sort:workspace:42:lock" - - -def test_lock_prevents_duplicate_run(): - """When the Redis lock already exists, the task should skip execution.""" - - mock_redis = MagicMock() - mock_redis.set.return_value = False # Lock already held - - with ( - patch( - "app.tasks.celery_tasks.document_tasks._get_ai_sort_redis", - return_value=mock_redis, - ), - patch( - "app.tasks.celery_tasks.document_tasks.get_celery_session_maker" - ) as mock_session_maker, - ): - import asyncio - - from app.tasks.celery_tasks.document_tasks import _ai_sort_workspace_async - - loop = asyncio.new_event_loop() - try: - loop.run_until_complete(_ai_sort_workspace_async(1, "user-123")) - finally: - loop.close() - - # Session maker should never be called since lock was not acquired - mock_session_maker.assert_not_called() diff --git a/surfsense_backend/tests/unit/services/test_folder_hierarchy.py b/surfsense_backend/tests/unit/services/test_folder_hierarchy.py index 9077f6b0e..d444b1a7d 100644 --- a/surfsense_backend/tests/unit/services/test_folder_hierarchy.py +++ b/surfsense_backend/tests/unit/services/test_folder_hierarchy.py @@ -49,8 +49,8 @@ async def test_creates_missing_folders_in_chain(): session.flush = mock_flush segments = [ - {"name": "Slack", "metadata": {"ai_sort": True, "ai_sort_level": 1}}, - {"name": "2025-03-15", "metadata": {"ai_sort": True, "ai_sort_level": 2}}, + {"name": "Slack", "metadata": {"source": "slack"}}, + {"name": "2025-03-15", "metadata": {"source": "slack"}}, ] result = await ensure_folder_hierarchy_with_depth_validation( diff --git a/surfsense_web/components/documents/DocumentsFilters.tsx b/surfsense_web/components/documents/DocumentsFilters.tsx index 86e91ee42..cda34eb48 100644 --- a/surfsense_web/components/documents/DocumentsFilters.tsx +++ b/surfsense_web/components/documents/DocumentsFilters.tsx @@ -1,6 +1,5 @@ "use client"; -import { IconBinaryTree, IconBinaryTreeFilled } from "@tabler/icons-react"; import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react"; import { useTranslations } from "next-intl"; import React, { useCallback, useMemo, useRef, useState } from "react"; @@ -9,12 +8,10 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Spinner } from "@/components/ui/spinner"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; -import { cn } from "@/lib/utils"; import { getDocumentTypeIcon } from "./DocumentTypeIcon"; export function DocumentsFilters({ @@ -24,9 +21,6 @@ export function DocumentsFilters({ onToggleType, activeTypes, onCreateFolder, - aiSortEnabled = false, - aiSortBusy = false, - onToggleAiSort, onUploadClick, }: { typeCounts: Partial>; @@ -35,9 +29,6 @@ export function DocumentsFilters({ onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; activeTypes: DocumentTypeEnum[]; onCreateFolder?: () => void; - aiSortEnabled?: boolean; - aiSortBusy?: boolean; - onToggleAiSort?: () => void; onUploadClick?: () => void; }) { const t = useTranslations("documents"); @@ -77,7 +68,7 @@ export function DocumentsFilters({ return (
- {/* New Folder + AI Sort + Filter Toggle Group */} + {/* New Folder + Filter Toggle Group */} {onCreateFolder && ( @@ -97,46 +88,6 @@ export function DocumentsFilters({ )} - {onToggleAiSort && ( - - - { - e.preventDefault(); - onToggleAiSort(); - }} - aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"} - aria-pressed={aiSortEnabled} - > - {aiSortBusy ? ( - - ) : aiSortEnabled ? ( - - ) : ( - - )} - - - - {aiSortBusy - ? "AI sort in progress..." - : aiSortEnabled - ? "AI sort active — click to disable" - : "Enable AI sort"} - - - )} - diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index 7c076e99a..7f1c2186e 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -93,8 +93,6 @@ export function FolderTreeView({ const [openContextMenuId, setOpenContextMenuId] = useState(null); - const [manuallyCollapsedAiIds, setManuallyCollapsedAiIds] = useState>(new Set()); - // Single subscription for rename state — derived boolean passed to each FolderNode const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom); const handleStartRename = useCallback( @@ -103,38 +101,6 @@ export function FolderTreeView({ ); const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]); - const aiSortFolderLevels = useMemo(() => { - const map = new Map(); - for (const f of folders) { - if (f.metadata?.ai_sort === true && typeof f.metadata?.ai_sort_level === "number") { - map.set(f.id, f.metadata.ai_sort_level as number); - } - } - return map; - }, [folders]); - - const handleToggleExpand = useCallback( - (folderId: number) => { - const aiLevel = aiSortFolderLevels.get(folderId); - if (aiLevel !== undefined && aiLevel < 4) { - // AI-auto-expanded folder: only toggle the manual-collapse set. - // Calling onToggleExpand would add it to expandedIds and fight auto-expand. - setManuallyCollapsedAiIds((prev) => { - const next = new Set(prev); - if (next.has(folderId)) { - next.delete(folderId); - } else { - next.add(folderId); - } - return next; - }); - return; - } - onToggleExpand(folderId); - }, - [onToggleExpand, aiSortFolderLevels] - ); - const effectiveActiveTypes = useMemo(() => { if ( activeTypes.includes("FILE" as DocumentTypeEnum) && @@ -280,14 +246,9 @@ export function FolderTreeView({ function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { const key = parentId ?? "root"; - const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => { - const aIsDate = a.metadata?.ai_sort === true && a.metadata?.ai_sort_level === 2; - const bIsDate = b.metadata?.ai_sort === true && b.metadata?.ai_sort_level === 2; - if (aIsDate && bIsDate) { - return b.name.localeCompare(a.name); - } - return a.position.localeCompare(b.position); - }); + const childFolders = (foldersByParent[key] ?? []) + .slice() + .sort((a, b) => a.position.localeCompare(b.position)); const visibleFolders = hasDescendantMatch ? childFolders.filter((f) => hasDescendantMatch[f.id]) : childFolders; @@ -317,14 +278,7 @@ export function FolderTreeView({ }; const isSearchAutoExpanded = !!searchQuery && !!hasDescendantMatch?.[f.id]; - const isAiAutoExpandCandidate = - f.metadata?.ai_sort === true && - typeof f.metadata?.ai_sort_level === "number" && - (f.metadata.ai_sort_level as number) < 4; - const isManuallyCollapsed = manuallyCollapsedAiIds.has(f.id); - const isExpanded = isManuallyCollapsed - ? isSearchAutoExpanded - : expandedIds.has(f.id) || isSearchAutoExpanded || isAiAutoExpandCandidate; + const isExpanded = expandedIds.has(f.id) || isSearchAutoExpanded; nodes.push( searchSpaces?.find((s) => s.id === searchSpaceId), - [searchSpaces, searchSpaceId] - ); - const aiSortEnabled = activeSearchSpace?.ai_file_sort_enabled ?? false; - const [aiSortBusy, setAiSortBusy] = useState(false); - const [aiSortConfirmOpen, setAiSortConfirmOpen] = useState(false); - - const handleToggleAiSort = useCallback(() => { - if (aiSortEnabled) { - // Disable: just update the setting, no confirmation needed - setAiSortBusy(true); - searchSpacesApiService - .updateSearchSpace({ id: searchSpaceId, data: { ai_file_sort_enabled: false } }) - .then(() => { - refetchSearchSpaces(); - toast.success("AI file sorting disabled"); - }) - .catch(() => toast.error("Failed to disable AI file sorting")) - .finally(() => setAiSortBusy(false)); - } else { - setAiSortConfirmOpen(true); - } - }, [aiSortEnabled, searchSpaceId, refetchSearchSpaces]); - - const handleConfirmEnableAiSort = useCallback(() => { - setAiSortConfirmOpen(false); - setAiSortBusy(true); - searchSpacesApiService - .updateSearchSpace({ id: searchSpaceId, data: { ai_file_sort_enabled: true } }) - .then(() => searchSpacesApiService.triggerAiSort(searchSpaceId)) - .then(() => { - refetchSearchSpaces(); - toast.success("AI file sorting enabled — organizing your documents in the background"); - }) - .catch(() => toast.error("Failed to enable AI file sorting")) - .finally(() => setAiSortBusy(false)); - }, [searchSpaceId, refetchSearchSpaces]); - const handleWatchLocalFolder = useCallback(async () => { const api = window.electronAPI; if (!api?.selectFolder) return; @@ -1251,9 +1208,6 @@ function AuthenticatedDocumentsSidebarBase({ onToggleType={onToggleType} activeTypes={activeTypes} onCreateFolder={() => handleCreateFolder(null)} - aiSortEnabled={aiSortEnabled} - aiSortBusy={aiSortBusy} - onToggleAiSort={handleToggleAiSort} />
@@ -1588,22 +1542,6 @@ function AuthenticatedDocumentsSidebarBase({ - - - - Enable AI File Sorting? - - All documents in this search space will be organized into folders by connector type, - date, and AI-generated categories. New documents will also be sorted automatically. - You can disable this at any time. - - - - Cancel - Enable - - - ); @@ -1935,7 +1873,6 @@ function AnonymousDocumentsSidebar({ onToggleType={() => {}} activeTypes={[]} onCreateFolder={() => gate("create folders")} - aiSortEnabled={false} onUploadClick={handleAnonUploadClick} />
diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts index acc0be07a..3c75da894 100644 --- a/surfsense_web/contracts/types/search-space.types.ts +++ b/surfsense_web/contracts/types/search-space.types.ts @@ -11,7 +11,6 @@ export const searchSpace = z.object({ api_access_enabled: z.boolean().optional().default(false), qna_custom_instructions: z.string().nullable(), shared_memory_md: z.string().nullable().optional(), - ai_file_sort_enabled: z.boolean().optional().default(false), member_count: z.number(), is_owner: z.boolean(), }); @@ -58,7 +57,6 @@ export const updateSearchSpaceRequest = z.object({ citations_enabled: true, api_access_enabled: true, qna_custom_instructions: true, - ai_file_sort_enabled: true, }) .partial(), }); diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index 3477a70f4..09e1894b2 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -139,17 +139,6 @@ class SearchSpacesApiService { return baseApiService.delete(`/api/v1/workspaces/${request.id}`, deleteSearchSpaceResponse); }; - /** - * Trigger AI file sorting for all documents in a search space - */ - triggerAiSort = async (searchSpaceId: number) => { - return baseApiService.post( - `/api/v1/workspaces/${searchSpaceId}/ai-sort`, - z.object({ message: z.string() }), - {} - ); - }; - /** * Leave a search space (remove own membership) * This is used by non-owners to leave a shared search space From f11cc3868c956fb6254fe6bdcc860c678f9fade7 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:09:25 +0530 Subject: [PATCH 11/26] feat(assistant-ui): add "Manage Connectors" option to ComposerAction and clean up DocumentsFilters component - Introduced a new dropdown menu item for managing connectors in the ComposerAction component. - Removed unused upload button and related props from the DocumentsFilters component for improved clarity and simplicity. - Updated LayoutDataProvider and RightPanel components to streamline state management and enhance layout consistency. --- .../components/assistant-ui/thread.tsx | 4 + .../components/documents/DocumentsFilters.tsx | 20 +-- .../layout/providers/LayoutDataProvider.tsx | 26 +--- .../layout/ui/right-panel/RightPanel.tsx | 75 ++--------- .../layout/ui/shell/LayoutShell.tsx | 14 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 127 ------------------ .../components/layout/ui/sidebar/Sidebar.tsx | 26 +++- 7 files changed, 44 insertions(+), 248 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 34ff4888f..c12f0f247 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1359,6 +1359,10 @@ const ComposerAction: FC = ({ Take a screenshot + setConnectorDialogOpen(true)}> + + Manage Connectors + { diff --git a/surfsense_web/components/documents/DocumentsFilters.tsx b/surfsense_web/components/documents/DocumentsFilters.tsx index cda34eb48..22075281f 100644 --- a/surfsense_web/components/documents/DocumentsFilters.tsx +++ b/surfsense_web/components/documents/DocumentsFilters.tsx @@ -1,9 +1,8 @@ "use client"; -import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react"; +import { FolderPlus, ListFilter, Search, X } from "lucide-react"; import { useTranslations } from "next-intl"; import React, { useCallback, useMemo, useRef, useState } from "react"; -import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; @@ -21,7 +20,6 @@ export function DocumentsFilters({ onToggleType, activeTypes, onCreateFolder, - onUploadClick, }: { typeCounts: Partial>; onSearch: (v: string) => void; @@ -29,15 +27,11 @@ export function DocumentsFilters({ onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; activeTypes: DocumentTypeEnum[]; onCreateFolder?: () => void; - onUploadClick?: () => void; }) { const t = useTranslations("documents"); const id = React.useId(); const inputRef = useRef(null); - const { openDialog: openUploadDialog } = useDocumentUploadDialog(); - const handleUpload = onUploadClick ?? openUploadDialog; - const [typeSearchQuery, setTypeSearchQuery] = useState(""); const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); const handleScroll = useCallback((e: React.UIEvent) => { @@ -207,18 +201,6 @@ export function DocumentsFilters({ )}
- - {/* Upload Button */} -
); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 2624384d3..ff7aefc70 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -12,7 +12,6 @@ import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current- import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; -import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; @@ -133,7 +132,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Documents sidebar state (shared atom so Composer can toggle it) const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom); - const setIsRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); // Open documents sidebar by default on desktop (docked mode) const documentsInitialized = useRef(false); @@ -335,9 +333,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid }, [threadsData, searchSpaceId]); // Navigation items - // Inbox, Automations, and Documents are rendered explicitly below "New chat" + // Inbox, Automations, and Artifacts are rendered explicitly below "New chat" // in the sidebar (also surfaced in the icon rail's collapsed mode via this - // list). Announcements has been moved to the avatar dropdown. + // list). Documents is embedded below Recents; announcements live in the avatar dropdown. const isAutomationsActive = pathname?.includes("/automations") === true; const isArtifactsActive = pathname?.endsWith("/artifacts") === true; const navItems: NavItem[] = useMemo( @@ -523,27 +521,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid return; } if (item.url === "#documents") { - if (!isMobile) { - if (!isDocumentsSidebarOpen) { - setIsDocumentsSidebarOpen(true); - setIsRightPanelCollapsed(false); + setIsDocumentsSidebarOpen((prev) => { + if (!prev) { setActiveSlideoutPanel(null); - } else { - setIsRightPanelCollapsed((prev) => !prev); } - } else { - setIsDocumentsSidebarOpen((prev) => { - if (!prev) { - setActiveSlideoutPanel(null); - } - return !prev; - }); - } + return !prev; + }); return; } router.push(item.url); }, - [router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed] + [router, setIsDocumentsSidebarOpen] ); const handleNewChat = useCallback(() => { diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index 8d9f0454f..501399bf5 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -3,10 +3,9 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { PanelRight } from "lucide-react"; import dynamic from "next/dynamic"; -import { type MouseEvent, startTransition, useEffect } from "react"; +import { startTransition, useEffect } from "react"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom"; -import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { type RightPanelTab, @@ -18,7 +17,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { artifactsPanelOpenAtom, closeArtifactsPanelAtom } from "@/features/chat-artifacts"; import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl"; import { cn } from "@/lib/utils"; -import { DocumentsSidebar } from "../sidebar"; const EditorPanelContent = dynamic( () => @@ -61,41 +59,9 @@ const ArtifactsPanelContent = dynamic( ); interface RightPanelProps { - documentsPanel?: { - open: boolean; - onOpenChange: (open: boolean) => void; - }; - showCollapseButton?: boolean; showTopBorder?: boolean; } -function isKeyboardClick(event: MouseEvent) { - return event.detail === 0; -} - -function CollapseButton({ onClick }: { onClick: () => void }) { - return ( - - - - - Collapse panel - - ); -} - interface RightPanelToggleButtonProps { className?: string; iconClassName?: string; @@ -108,7 +74,6 @@ export function RightPanelToggleButton({ disabled = false, }: RightPanelToggleButtonProps) { const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); - const documentsOpen = useAtomValue(documentsSidebarOpenAtom); const reportState = useAtomValue(reportPanelAtom); const editorState = useAtomValue(editorPanelAtom); const hitlEditState = useAtomValue(hitlEditPanelAtom); @@ -124,8 +89,7 @@ export function RightPanelToggleButton({ : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const citationOpen = citationState.isOpen && citationState.chunkId != null; - const hasContent = - documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; + const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; const label = collapsed ? "Expand panel" : "Collapse panel"; if (!hasContent) return null; @@ -162,7 +126,6 @@ export function RightPanelToggleButton({ */ export function RightPanelExpandButton() { const [collapsed] = useAtom(rightPanelCollapsedAtom); - const documentsOpen = useAtomValue(documentsSidebarOpenAtom); const reportState = useAtomValue(reportPanelAtom); const editorState = useAtomValue(editorPanelAtom); const hitlEditState = useAtomValue(hitlEditPanelAtom); @@ -178,8 +141,7 @@ export function RightPanelExpandButton() { : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const citationOpen = citationState.isOpen && citationState.chunkId != null; - const hasContent = - documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; + const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; if (!collapsed || !hasContent) return null; @@ -201,8 +163,7 @@ const PANEL_WIDTHS = { /** * Priority order used to fall back to another open surface when the active - * tab's content closes. Artifacts sit just above the always-available sources - * tab. + * tab's content closes. The neutral "sources" tab is kept as the closed state. */ const TAB_FALLBACK_ORDER: RightPanelTab[] = [ "hitl-edit", @@ -221,11 +182,7 @@ function resolveEffectiveTab( return TAB_FALLBACK_ORDER.find((tab) => openByTab[tab]) ?? "sources"; } -export function RightPanel({ - documentsPanel, - showCollapseButton = true, - showTopBorder = false, -}: RightPanelProps) { +export function RightPanel({ showTopBorder = false }: RightPanelProps) { const [activeTab] = useAtom(rightPanelTabAtom); const reportState = useAtomValue(reportPanelAtom); const closeReport = useSetAtom(closeReportPanelAtom); @@ -237,9 +194,8 @@ export function RightPanel({ const closeCitation = useSetAtom(closeCitationPanelAtom); const artifactsOpen = useAtomValue(artifactsPanelOpenAtom); const closeArtifacts = useSetAtom(closeArtifactsPanelAtom); - const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); + const [collapsed] = useAtom(rightPanelCollapsedAtom); - const documentsOpen = documentsPanel?.open ?? false; const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && @@ -278,11 +234,10 @@ export function RightPanel({ ]); const isVisible = - (documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) && - !collapsed; + (reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) && !collapsed; const effectiveTab = resolveEffectiveTab(activeTab, { - sources: documentsOpen, + sources: false, report: reportOpen, editor: editorOpen, "hitl-edit": hitlEditOpen, @@ -291,10 +246,6 @@ export function RightPanel({ }); const targetWidth = PANEL_WIDTHS[effectiveTab]; - const collapseButton = showCollapseButton ? ( - setCollapsed(true)} /> - ) : null; - if (!isVisible) return null; return ( @@ -306,16 +257,6 @@ export function RightPanel({ )} >
- {effectiveTab === "sources" && documentsOpen && documentsPanel && ( -
- -
- )} {effectiveTab === "report" && reportOpen && (
- {/* Right panel — tabbed Sources/Report (desktop only) */} - {documentsPanel ? ( - - ) : null} + {/* Right panel — Report/Editor/Citations/Artifacts (desktop only) */} + )} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 250f4e467..094ecab46 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -12,7 +12,6 @@ import { Paperclip, Server, Trash2, - Unplug, Upload, X, } from "lucide-react"; @@ -25,8 +24,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom"; import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; -import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; -import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; @@ -58,7 +55,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { Skeleton } from "@/components/ui/skeleton"; @@ -67,7 +63,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode"; import { useLoginGate } from "@/contexts/login-gate"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; @@ -167,18 +162,6 @@ interface WatchedFolderEntry { active: boolean; } -const SHOWCASE_CONNECTORS = [ - { type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" }, - { type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" }, - { type: "NOTION_CONNECTOR", label: "Notion" }, - { type: "YOUTUBE_CONNECTOR", label: "YouTube" }, - { type: "GOOGLE_CALENDAR_CONNECTOR", label: "Google Calendar" }, - { type: "SLACK_CONNECTOR", label: "Slack" }, - { type: "LINEAR_CONNECTOR", label: "Linear" }, - { type: "JIRA_CONNECTOR", label: "Jira" }, - { type: "GITHUB_CONNECTOR", label: "GitHub" }, -] as const; - interface DocumentsSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -228,11 +211,8 @@ function AuthenticatedDocumentsSidebarBase({ const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null; const { etlService } = useRuntimeConfig(); const searchSpaceId = getWorkspaceIdNumber(params) ?? 0; - const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const openEditorPanel = useSetAtom(openEditorPanelAtom); const { data: agentFlags } = useAtomValue(agentFlagsAtom); - const { data: connectors } = useAtomValue(connectorsAtom); - const connectorCount = connectors?.length ?? 0; const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); @@ -1116,76 +1096,9 @@ function AuthenticatedDocumentsSidebarBase({ const showCloudSkeleton = currentFilesystemTab === "cloud" && (zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete"); - const connectorButtonLabel = connectorCount > 0 ? "Manage connectors" : "Connect your connectors"; const cloudContent = ( <> - {/* Connected tools strip */} - - {isElectron && (
- {/* Connectors strip (gated) */} - - {/* Filters & upload */}
@@ -1873,7 +1747,6 @@ function AnonymousDocumentsSidebar({ onToggleType={() => {}} activeTypes={[]} onCreateFolder={() => gate("create folders")} - onUploadClick={handleAnonUploadClick} />
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 6f17a95ce..589c82eb8 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -16,6 +16,7 @@ import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import { ChatListItem } from "./ChatListItem"; import { CreditBalanceDisplay } from "./CreditBalanceDisplay"; +import { DocumentsSidebar } from "./DocumentsSidebar"; import { NavSection } from "./NavSection"; import { SidebarButton } from "./SidebarButton"; import { SidebarCollapseButton } from "./SidebarCollapseButton"; @@ -76,6 +77,10 @@ interface SidebarProps { onChatArchive?: (chat: ChatItem) => void; onViewAllChats?: () => void; isAllChatsActive?: boolean; + documentsPanel?: { + open: boolean; + onOpenChange: (open: boolean) => void; + }; user: User; onSettings?: () => void; onManageMembers?: () => void; @@ -113,6 +118,7 @@ export function Sidebar({ onChatArchive, onViewAllChats, isAllChatsActive = false, + documentsPanel, user, onSettings, onManageMembers, @@ -136,11 +142,9 @@ export function Sidebar({ const t = useTranslations("sidebar"); const [openDropdownChatId, setOpenDropdownChatId] = useState(null); - // Inbox, Automations, and Documents are rendered explicitly right below + // Inbox, Automations, and Artifacts are rendered explicitly right below // New Chat. Pull them out of the nav items list so they don't also appear - // in the bottom NavSection. Documents is only present in navItems on - // mobile; Automations is identified by URL suffix so the same code path - // works across search spaces. + // in the bottom NavSection. Documents is embedded below Recents. const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]); const automationsItem = useMemo( () => navItems.find((item) => item.url.endsWith("/automations")), @@ -280,7 +284,10 @@ export function Sidebar({ {t("no_chats")}

)}
+ {documentsPanel?.open ? ( +
+ +
+ ) : null}
)} From 62c0573b1cede169c47b46dd28515b5c7268d957 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:11:53 +0530 Subject: [PATCH 12/26] feat(sidebar): enhance document management UI and improve layout structure - Updated the FolderTreeView component to clarify upload instructions. - Introduced a new renderDocumentTree function in DocumentsSidebar for better organization and readability. - Refactored the Sidebar component to utilize SidebarSection for improved layout consistency. - Added contentClassName prop to SidebarSection for flexible styling options. --- .../components/documents/FolderTreeView.tsx | 2 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 181 +++++++++++------- .../components/layout/ui/sidebar/Sidebar.tsx | 9 +- .../layout/ui/sidebar/SidebarSection.tsx | 6 +- 4 files changed, 126 insertions(+), 72 deletions(-) diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index 7f1c2186e..daa23408f 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -336,7 +336,7 @@ export function FolderTreeView({

No documents found

- Use the upload button or connect a source above + Use the plus menu to upload files or manage connectors

); diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 094ecab46..bf2e185a7 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -1097,6 +1097,81 @@ function AuthenticatedDocumentsSidebarBase({ currentFilesystemTab === "cloud" && (zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete"); + const renderDocumentTree = ({ + documents = searchFilteredDocuments, + activeTypesForTree = activeTypes, + searchQuery = debouncedSearch.trim() || undefined, + }: { + documents?: DocumentNodeDoc[]; + activeTypesForTree?: DocumentTypeEnum[]; + searchQuery?: string; + } = {}) => ( +
+ {deletableSelectedIds.length > 0 && ( +
+ +
+ )} + + {showCloudSkeleton ? ( + + ) : ( + { + if (openMemoryDocument(doc)) return; + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); + }} + onEditDocument={(doc) => { + if (openMemoryDocument(doc)) return; + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); + }} + onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} + onMoveDocument={handleMoveDocument} + onResetDocument={handleResetMemoryDocument} + onExportDocument={handleExportDocument} + onVersionHistory={(doc) => setVersionDocId(doc.id)} + activeTypes={activeTypesForTree} + onDropIntoFolder={handleDropIntoFolder} + onReorderFolder={handleReorderFolder} + watchedFolderIds={watchedFolderIds} + onRescanFolder={handleRescanFolder} + onStopWatchingFolder={handleStopWatching} + onExportFolder={handleExportFolder} + /> + )} +
+ ); + const cloudContent = ( <> {isElectron && ( @@ -1124,70 +1199,7 @@ function AuthenticatedDocumentsSidebarBase({ />
-
- {deletableSelectedIds.length > 0 && ( -
- -
- )} - - {showCloudSkeleton ? ( - - ) : ( - { - if (openMemoryDocument(doc)) return; - openEditorPanel({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - }} - onEditDocument={(doc) => { - if (openMemoryDocument(doc)) return; - openEditorPanel({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - }} - onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} - onMoveDocument={handleMoveDocument} - onResetDocument={handleResetMemoryDocument} - onExportDocument={handleExportDocument} - onVersionHistory={(doc) => setVersionDocId(doc.id)} - activeTypes={activeTypes} - onDropIntoFolder={handleDropIntoFolder} - onReorderFolder={handleReorderFolder} - watchedFolderIds={watchedFolderIds} - onRescanFolder={handleRescanFolder} - onStopWatchingFolder={handleStopWatching} - onExportFolder={handleExportFolder} - /> - )} -
+ {renderDocumentTree()}
); @@ -1459,8 +1471,12 @@ function AuthenticatedDocumentsSidebarBase({ if (embedded) { return ( -
- {documentsContent} +
+ {renderDocumentTree({ + documents: treeDocumentsWithMemory, + activeTypesForTree: [], + searchQuery: undefined, + })}
); } @@ -1830,8 +1846,37 @@ function AnonymousDocumentsSidebar({ if (embedded) { return ( -
- {documentsContent} +
+ {}} + mentionedDocKeys={mentionedDocKeys} + onToggleChatMention={handleToggleChatMention} + onToggleFolderSelect={() => {}} + onRenameFolder={() => gate("rename folders")} + onDeleteFolder={() => gate("delete folders")} + onMoveFolder={() => gate("organize folders")} + onCreateFolder={() => gate("create folders")} + onPreviewDocument={() => gate("preview documents")} + onEditDocument={() => gate("edit documents")} + onDeleteDocument={async () => { + handleRemoveDoc(); + setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1)); + return true; + }} + onMoveDocument={() => gate("organize documents")} + onExportDocument={() => gate("export documents")} + onVersionHistory={() => gate("view version history")} + activeTypes={[]} + onDropIntoFolder={async () => gate("organize documents")} + onReorderFolder={async () => gate("organize folders")} + watchedFolderIds={new Set()} + onRescanFolder={() => gate("watch local folders")} + onStopWatchingFolder={() => gate("watch local folders")} + onExportFolder={() => gate("export folders")} + />
); } diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 589c82eb8..27370d30f 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -336,13 +336,18 @@ export function Sidebar({ )} {documentsPanel?.open ? ( -
+ -
+ ) : null}
)} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index c041dec86..573173cf5 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -13,6 +13,7 @@ interface SidebarSectionProps { alwaysShowAction?: boolean; persistentAction?: React.ReactNode; className?: string; + contentClassName?: string; fillHeight?: boolean; } @@ -24,6 +25,7 @@ export function SidebarSection({ alwaysShowAction = false, persistentAction, className, + contentClassName, fillHeight = false, }: SidebarSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); @@ -69,7 +71,9 @@ export function SidebarSection({
-
{children}
+
+ {children} +
); From 7bc47aada0d2658c6ba4cde44080e0823829deee Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:22:33 +0530 Subject: [PATCH 13/26] feat(sidebar): add embedded documents menu for enhanced document management - Introduced EmbeddedDocumentsMenu component to facilitate document type filtering and folder creation. - Updated AuthenticatedDocumentsSidebarBase and AnonymousDocumentsSidebar to integrate the new menu. - Enhanced UI with dropdown functionality for improved user experience in document management. --- .../layout/ui/sidebar/DocumentsSidebar.tsx | 106 +++++++++++++++++- .../components/layout/ui/sidebar/Sidebar.tsx | 2 +- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index bf2e185a7..1f038a39b 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -7,8 +7,11 @@ import { ChevronRight, FileText, FolderClock, + FolderPlus, Laptop, + ListFilter, Lock, + MoreHorizontal, Paperclip, Server, Trash2, @@ -35,6 +38,7 @@ import { import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import { DocumentsFilters } from "@/components/documents/DocumentsFilters"; +import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; @@ -57,6 +61,16 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -72,6 +86,7 @@ import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-fetch"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; +import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { buildBackendUrl } from "@/lib/env-config"; import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { getWorkspaceIdNumber } from "@/lib/route-params"; @@ -117,6 +132,75 @@ function downloadTextFile(content: string, fileName: string, type = "text/markdo document.body.removeChild(a); URL.revokeObjectURL(url); } + +function EmbeddedDocumentsMenu({ + typeCounts, + activeTypes, + onToggleType, + onCreateFolder, +}: { + typeCounts: Partial>; + activeTypes: DocumentTypeEnum[]; + onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; + onCreateFolder: () => void; +}) { + const documentTypes = useMemo( + () => Object.keys(typeCounts).sort() as DocumentTypeEnum[], + [typeCounts] + ); + + return ( + + + + + + + + New folder + + + + + Filter by type + + + {documentTypes.length > 0 ? ( + documentTypes.map((type) => ( + onToggleType(type, checked === true)} + onSelect={(event) => event.preventDefault()} + > + {getDocumentTypeIcon(type, "h-4 w-4")} + {getDocumentTypeLabel(type)} + + {typeCounts[type] ?? 0} + + + )) + ) : ( + No document types + )} + + + + + ); +} + const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1"; const MAX_LOCAL_FILESYSTEM_ROOTS = 10; @@ -1472,11 +1556,15 @@ function AuthenticatedDocumentsSidebarBase({ if (embedded) { return (
- {renderDocumentTree({ - documents: treeDocumentsWithMemory, - activeTypesForTree: [], - searchQuery: undefined, - })} +
+ handleCreateFolder(null)} + /> +
+ {renderDocumentTree()}
); } @@ -1847,6 +1935,14 @@ function AnonymousDocumentsSidebar({ if (embedded) { return (
+
+ {}} + onCreateFolder={() => gate("create folders")} + /> +
-
+
Date: Mon, 6 Jul 2026 11:28:41 +0530 Subject: [PATCH 14/26] feat(sidebar): refactor DocumentsSidebar to utilize SidebarSection for improved layout - Replaced div wrappers in AuthenticatedDocumentsSidebarBase and AnonymousDocumentsSidebar with SidebarSection for better structure and consistency. - Updated EmbeddedDocumentsMenu to enhance document management functionality. - Changed icon from MoreHorizontal to SlidersVertical for improved visual representation. - Adjusted SidebarSection styling for better alignment and spacing. --- .../layout/ui/sidebar/DocumentsSidebar.tsx | 33 ++++++++++++------- .../components/layout/ui/sidebar/Sidebar.tsx | 9 ++--- .../layout/ui/sidebar/SidebarSection.tsx | 12 +++++-- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 1f038a39b..09f53db11 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -11,9 +11,9 @@ import { Laptop, ListFilter, Lock, - MoreHorizontal, Paperclip, Server, + SlidersVertical, Trash2, Upload, X, @@ -92,6 +92,7 @@ import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { getWorkspaceIdNumber } from "@/lib/route-params"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { queries } from "@/zero/queries/index"; +import { SidebarSection } from "./SidebarSection"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; const DesktopLocalTabContent = dynamic( @@ -133,7 +134,7 @@ function downloadTextFile(content: string, fileName: string, type = "text/markdo URL.revokeObjectURL(url); } -function EmbeddedDocumentsMenu({ +export function EmbeddedDocumentsMenu({ typeCounts, activeTypes, onToggleType, @@ -159,7 +160,7 @@ function EmbeddedDocumentsMenu({ className="relative h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground" aria-label="Document actions" > - + {activeTypes.length > 0 ? ( ) : null} @@ -1555,17 +1556,22 @@ function AuthenticatedDocumentsSidebarBase({ if (embedded) { return ( -
-
+ handleCreateFolder(null)} /> -
+ } + > {renderDocumentTree()} -
+ ); } @@ -1934,15 +1940,20 @@ function AnonymousDocumentsSidebar({ if (embedded) { return ( -
-
+ {}} onCreateFolder={() => gate("create folders")} /> -
+ } + > gate("watch local folders")} onExportFolder={() => gate("export folders")} /> -
+ ); } diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 1ad370fab..6477e27a8 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -336,18 +336,13 @@ export function Sidebar({ )} {documentsPanel?.open ? ( - +
- +
) : null}
)} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index 573173cf5..ac995094a 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -41,7 +41,7 @@ export function SidebarSection({ className )} > -
+
{title} {persistentAction}
+
+ {persistentAction} +
)}
From 53259b610584922bc7b96e0e8efa617007ef9b6b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:44:45 +0530 Subject: [PATCH 15/26] feat(sidebar): introduce SidebarListItem component for consistent sidebar item styling - Added SidebarListItem component to standardize the appearance of sidebar items across DocumentNode and FolderNode. - Refactored DocumentNode and FolderNode to utilize SidebarListItem for improved layout and interaction. - Updated ChatListItem to leverage sidebarListItemClassName for consistent styling. - Enhanced SidebarSection to improve content rendering and visibility transitions. --- .../components/documents/DocumentNode.tsx | 13 +++-- .../components/documents/FolderNode.tsx | 11 ++--- .../layout/ui/sidebar/ChatListItem.tsx | 12 ++--- .../layout/ui/sidebar/DocumentsSidebar.tsx | 41 +++++++++------- .../layout/ui/sidebar/SidebarListItem.tsx | 47 +++++++++++++++++++ .../layout/ui/sidebar/SidebarSection.tsx | 20 ++++++-- 6 files changed, 104 insertions(+), 40 deletions(-) create mode 100644 surfsense_web/components/layout/ui/sidebar/SidebarListItem.tsx diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index a13bd0079..1d627ee36 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { cn } from "@/lib/utils"; +import { SidebarListItem } from "../layout/ui/sidebar/SidebarListItem"; import { DND_TYPES } from "./FolderNode"; import { isVersionableType } from "./version-history"; @@ -165,13 +166,11 @@ export const DocumentNode = React.memo(function DocumentNode({ return ( -
{(() => { @@ -358,7 +357,7 @@ export const DocumentNode = React.memo(function DocumentNode({ -
+
{contextMenuOpen && ( diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index 2506713a4..849de2ea9 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -5,7 +5,6 @@ import { ChevronDown, ChevronRight, Download, - Eye, EyeOff, Folder, FolderOpen, @@ -18,6 +17,7 @@ import { } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; +import { SidebarListItem } from "@/components/layout/ui/sidebar/SidebarListItem"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -254,15 +254,14 @@ export const FolderNode = React.memo(function FolderNode({ return ( - {/* biome-ignore lint/a11y/useSemanticElements: div required for drag/drop refs */} -
)} -
+
{!isRenaming && contextMenuOpen && ( diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index c854225a2..a027cfb9c 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -14,6 +14,7 @@ import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; import { useTypewriter } from "@/hooks/use-typewriter"; import { cn } from "@/lib/utils"; +import { sidebarListItemClassName } from "./SidebarListItem"; interface ChatListItemProps { name: string; @@ -66,12 +67,11 @@ export function ChatListItem({ onMouseEnter={onPrefetch} onFocus={onPrefetch} {...(isMobile ? longPressHandlers : {})} - className={cn( - "h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal", - "group-hover/item:bg-accent group-hover/item:text-accent-foreground", - "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", - isActive && "bg-accent text-accent-foreground" - )} + className={sidebarListItemClassName({ + active: isActive, + className: + "justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + })} > {animatedName} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 09f53db11..234ce7124 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -1556,22 +1556,31 @@ function AuthenticatedDocumentsSidebarBase({ if (embedded) { return ( - handleCreateFolder(null)} - /> - } - > - {renderDocumentTree()} - + <> + handleCreateFolder(null)} + /> + } + > + {renderDocumentTree()} + + + + ); } diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarListItem.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarListItem.tsx new file mode 100644 index 000000000..4799970f4 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarListItem.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type React from "react"; +import { forwardRef } from "react"; +import { cn } from "@/lib/utils"; + +type SidebarListItemElementProps = React.HTMLAttributes; + +interface SidebarListItemProps extends SidebarListItemElementProps { + active?: boolean; + dragging?: boolean; + interactive?: boolean; +} + +export const SidebarListItem = forwardRef( + ({ active, dragging, interactive = true, className, children, ...props }, ref) => ( +
+ {children} +
+ ) +); + +SidebarListItem.displayName = "SidebarListItem"; + +export function sidebarListItemClassName({ + active, + dragging, + interactive = true, + className, +}: { + active?: boolean; + dragging?: boolean; + interactive?: boolean; + className?: string; +}) { + return cn( + "group group/sidebar-list-item flex h-8 w-full items-center rounded-md text-left text-sm select-none", + interactive && "cursor-pointer hover:bg-accent hover:text-accent-foreground", + active && "bg-accent text-accent-foreground", + dragging && "opacity-40", + className + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index ac995094a..73cf8485f 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -2,7 +2,7 @@ import { ChevronRight } from "lucide-react"; import { useState } from "react"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Collapsible, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; interface SidebarSectionProps { @@ -78,11 +78,21 @@ export function SidebarSection({ )}
- -
- {children} +
+
+
+ {children} +
- +
); } From e24abd7eac396ae4332410d97d101049ff5716e9 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:49:41 +0530 Subject: [PATCH 16/26] refactor(sidebar): streamline DocumentsSidebar and FolderTreeView for improved performance - Removed unnecessary filtering logic in FolderTreeView to simplify document rendering. - Updated DocumentsSidebar to eliminate redundant props and enhance layout consistency. - Adjusted Sidebar component to conditionally render DocumentsSidebar based on the documentsPanel state. --- .../components/documents/FolderTreeView.tsx | 20 +- .../layout/ui/shell/LayoutShell.tsx | 10 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 947 ++---------------- .../layout/ui/sidebar/MobileSidebar.tsx | 9 + .../components/layout/ui/sidebar/Sidebar.tsx | 6 +- 5 files changed, 102 insertions(+), 890 deletions(-) diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index daa23408f..ed06abcee 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -260,16 +260,6 @@ export function FolderTreeView({ const nodes: React.ReactNode[] = []; - if (parentId === null) { - const processingDocs = childDocs.filter((d) => { - const state = d.status?.state; - return state === "pending" || state === "processing"; - }); - for (const d of processingDocs) { - nodes.push(renderDocumentNode(d, depth)); - } - } - for (let i = 0; i < visibleFolders.length; i++) { const f = visibleFolders[i]; const siblingPositions = { @@ -314,15 +304,7 @@ export function FolderTreeView({ } } - const remainingDocs = - parentId === null - ? childDocs.filter((d) => { - const state = d.status?.state; - return state !== "pending" && state !== "processing"; - }) - : childDocs; - - for (const d of remainingDocs) { + for (const d of childDocs) { nodes.push(renderDocumentNode(d, depth)); } diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 870a87407..ea63d3583 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -27,7 +27,6 @@ import { RightPanelToggleButton, } from "../right-panel/RightPanel"; import { - DocumentsSidebar, InboxSidebarContent, MobileSidebar, MobileSidebarTrigger, @@ -313,6 +312,7 @@ export function LayoutShell({ onChatArchive={onChatArchive} onViewAllChats={onViewAllChats} isAllChatsActive={isAllChatsPage} + documentsPanel={documentsPanel} user={user} onSettings={onSettings} onManageMembers={onManageMembers} @@ -366,14 +366,6 @@ export function LayoutShell({ )} - - {/* Mobile Documents Sidebar - separate (not part of slide-out group) */} - {documentsPanel && ( - - )}
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 234ce7124..60cb6659f 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -2,42 +2,18 @@ import { useQuery } from "@rocicorp/zero/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { - ChevronLeft, - ChevronRight, - FileText, - FolderClock, - FolderPlus, - Laptop, - ListFilter, - Lock, - Paperclip, - Server, - SlidersVertical, - Trash2, - Upload, - X, -} from "lucide-react"; -import dynamic from "next/dynamic"; -import Link from "next/link"; +import { FolderPlus, ListFilter, SlidersVertical, Trash2 } from "lucide-react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom"; import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; -import { - folderWatchDialogOpenAtom, - folderWatchInitialFolderAtom, -} from "@/atoms/folder-sync/folder-sync.atoms"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; -import { DocumentsFilters } from "@/components/documents/DocumentsFilters"; import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; @@ -45,10 +21,7 @@ import { FolderTreeView } from "@/components/documents/FolderTreeView"; import { VersionHistoryDialog } from "@/components/documents/version-history"; import { useRuntimeConfig } from "@/components/providers/runtime-config"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; -import { - DEFAULT_EXCLUDE_PATTERNS, - FolderWatchDialog, -} from "@/components/sources/FolderWatchDialog"; +import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog"; import { AlertDialog, AlertDialogAction, @@ -60,7 +33,6 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -73,15 +45,10 @@ import { } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode"; import { useLoginGate } from "@/contexts/login-gate"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; -import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI, usePlatform } from "@/hooks/use-platform"; -import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-fetch"; @@ -93,12 +60,6 @@ import { getWorkspaceIdNumber } from "@/lib/route-params"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { queries } from "@/zero/queries/index"; import { SidebarSection } from "./SidebarSection"; -import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; - -const DesktopLocalTabContent = dynamic( - () => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent), - { ssr: false } -); const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"]; const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [ @@ -202,9 +163,6 @@ export function EmbeddedDocumentsMenu({ ); } -const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1"; -const MAX_LOCAL_FILESYSTEM_ROOTS = 10; - function CloudDocumentsSkeleton() { const rows = [ { id: "row-1", widthClass: "w-44" }, @@ -231,12 +189,6 @@ function CloudDocumentsSkeleton() { ); } -type FilesystemSettings = { - mode: "cloud" | "desktop_local_folder"; - localRootPaths: string[]; - updatedAt: string; -}; - interface WatchedFolderEntry { path: string; name: string; @@ -248,14 +200,8 @@ interface WatchedFolderEntry { } interface DocumentsSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - isDocked?: boolean; - onDockedChange?: (docked: boolean) => void; /** When true, renders content without any wrapper — parent provides the container */ embedded?: boolean; - /** Optional action element rendered in the header row (e.g. collapse button) */ - headerAction?: React.ReactNode; } export function DocumentsSidebar(props: DocumentsSidebarProps) { @@ -280,162 +226,19 @@ function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) { } function AuthenticatedDocumentsSidebarBase({ - open, - onOpenChange, - isDocked = false, - onDockedChange, embedded = false, - headerAction, desktopFeaturesEnabled, }: DocumentsSidebarProps & { desktopFeaturesEnabled: boolean }) { const t = useTranslations("documents"); - const tSidebar = useTranslations("sidebar"); const params = useParams(); - const isMobile = !useMediaQuery("(min-width: 640px)"); const platformElectronAPI = useElectronAPI(); const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null; const { etlService } = useRuntimeConfig(); const searchSpaceId = getWorkspaceIdNumber(params) ?? 0; const openEditorPanel = useSetAtom(openEditorPanelAtom); - const { data: agentFlags } = useAtomValue(agentFlagsAtom); - const [search, setSearch] = useState(""); - const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); - const [filesystemSettings, setFilesystemSettings] = useState(null); - const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false); - const [pendingLocalPath, setPendingLocalPath] = useState(null); const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); - const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom); - const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom); - const localFilesystemEnabled = agentFlags?.enable_desktop_local_filesystem === true; - const isElectron = - desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI; - - useEffect(() => { - if (!electronAPI?.getAgentFilesystemSettings) return; - let mounted = true; - electronAPI - .getAgentFilesystemSettings(searchSpaceId) - .then((settings: FilesystemSettings) => { - if (!mounted) return; - setFilesystemSettings(settings); - }) - .catch(() => { - if (!mounted) return; - setFilesystemSettings({ - mode: "cloud", - localRootPaths: [], - updatedAt: new Date().toISOString(), - }); - }); - return () => { - mounted = false; - }; - }, [electronAPI, searchSpaceId]); - - const hasLocalFilesystemTrust = useCallback(() => { - try { - return window.localStorage.getItem(LOCAL_FILESYSTEM_TRUST_KEY) === "true"; - } catch { - return false; - } - }, []); - - const localRootPaths = filesystemSettings?.localRootPaths ?? []; - const canAddMoreLocalRoots = localRootPaths.length < MAX_LOCAL_FILESYSTEM_ROOTS; - - const applyLocalRootPath = useCallback( - async (path: string) => { - if (!electronAPI?.setAgentFilesystemSettings) return; - const nextLocalRootPaths = [path, ...localRootPaths] - .filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index) - .slice(0, MAX_LOCAL_FILESYSTEM_ROOTS); - if (nextLocalRootPaths.length === localRootPaths.length) return; - const updated = await electronAPI.setAgentFilesystemSettings( - { - mode: "desktop_local_folder", - localRootPaths: nextLocalRootPaths, - }, - searchSpaceId - ); - setFilesystemSettings(updated); - }, - [electronAPI, localRootPaths, searchSpaceId] - ); - - const runPickLocalRoot = useCallback(async () => { - if (!electronAPI?.pickAgentFilesystemRoot) return; - const picked = await electronAPI.pickAgentFilesystemRoot(); - if (!picked) return; - await applyLocalRootPath(picked); - }, [applyLocalRootPath, electronAPI]); - - const handlePickFilesystemRoot = useCallback(async () => { - if (!canAddMoreLocalRoots) return; - if (hasLocalFilesystemTrust()) { - await runPickLocalRoot(); - return; - } - if (!electronAPI?.pickAgentFilesystemRoot) return; - const picked = await electronAPI.pickAgentFilesystemRoot(); - if (!picked) return; - setPendingLocalPath(picked); - setLocalTrustDialogOpen(true); - }, [canAddMoreLocalRoots, electronAPI, hasLocalFilesystemTrust, runPickLocalRoot]); - - const handleRemoveFilesystemRoot = useCallback( - async (rootPathToRemove: string) => { - if (!electronAPI?.setAgentFilesystemSettings) return; - const updated = await electronAPI.setAgentFilesystemSettings( - { - mode: "desktop_local_folder", - localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove), - }, - searchSpaceId - ); - setFilesystemSettings(updated); - }, - [electronAPI, localRootPaths, searchSpaceId] - ); - - const handleClearFilesystemRoots = useCallback(async () => { - if (!electronAPI?.setAgentFilesystemSettings) return; - const updated = await electronAPI.setAgentFilesystemSettings( - { - mode: "desktop_local_folder", - localRootPaths: [], - }, - searchSpaceId - ); - setFilesystemSettings(updated); - }, [electronAPI, searchSpaceId]); - - const handleFilesystemTabChange = useCallback( - async (tab: "cloud" | "local") => { - if (!electronAPI?.setAgentFilesystemSettings) return; - const updated = await electronAPI.setAgentFilesystemSettings( - { - mode: tab === "cloud" ? "cloud" : "desktop_local_folder", - }, - searchSpaceId - ); - setFilesystemSettings(updated); - }, - [electronAPI, searchSpaceId] - ); - - const handleWatchLocalFolder = useCallback(async () => { - const api = window.electronAPI; - if (!api?.selectFolder) return; - - const folderPath = await api.selectFolder(); - if (!folderPath) return; - - const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath; - setWatchInitialFolder({ path: folderPath, name: folderName }); - setFolderWatchOpen(true); - }, [setWatchInitialFolder, setFolderWatchOpen]); const refreshWatchedIds = useCallback(async () => { if (!electronAPI?.getWatchedFolders) return; @@ -1021,12 +824,6 @@ function AuthenticatedDocumentsSidebarBase({ [treeDocuments] ); - const searchFilteredDocuments = useMemo(() => { - const query = debouncedSearch.trim().toLowerCase(); - if (!query) return treeDocumentsWithMemory; - return treeDocumentsWithMemory.filter((d) => d.title.toLowerCase().includes(query)); - }, [treeDocumentsWithMemory, debouncedSearch]); - const openMemoryDocument = useCallback( (doc: DocumentNodeDoc) => { if (doc.document_type === "USER_MEMORY") { @@ -1162,30 +959,13 @@ function AuthenticatedDocumentsSidebarBase({ [deleteDocumentMutation, t, setSidebarDocs] ); - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open && isMobile) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange, isMobile]); - - const showFilesystemTabs = - !isMobile && !!electronAPI && !!filesystemSettings && localFilesystemEnabled; - const currentFilesystemTab = - localFilesystemEnabled && filesystemSettings?.mode === "desktop_local_folder" - ? "local" - : "cloud"; const showCloudSkeleton = - currentFilesystemTab === "cloud" && - (zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete"); + zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete"; const renderDocumentTree = ({ - documents = searchFilteredDocuments, + documents = treeDocumentsWithMemory, activeTypesForTree = activeTypes, - searchQuery = debouncedSearch.trim() || undefined, + searchQuery, }: { documents?: DocumentNodeDoc[]; activeTypesForTree?: DocumentTypeEnum[]; @@ -1257,303 +1037,6 @@ function AuthenticatedDocumentsSidebarBase({
); - const cloudContent = ( - <> - {isElectron && ( - - )} - -
-
- handleCreateFolder(null)} - /> -
- - {renderDocumentTree()} -
- - ); - - const localContent = ( - { - openEditorPanel({ - kind: "local_file", - localFilePath, - title: localFilePath.split("/").pop() || localFilePath, - searchSpaceId, - }); - }} - electronAvailable={!!electronAPI} - /> - ); - - const documentsContent = ( - <> -
-
-
- {isMobile && ( - - )} -

{t("title") || "Documents"}

- {showFilesystemTabs && ( - { - void handleFilesystemTabChange(value === "local" ? "local" : "cloud"); - }} - > - - - - Cloud - - - - Local - - - - )} -
-
- {!isMobile && onDockedChange && ( - - - - - - {isDocked ? "Collapse panel" : "Expand panel"} - - - )} - {headerAction} -
-
-
- {showFilesystemTabs ? ( - { - void handleFilesystemTabChange(value === "local" ? "local" : "cloud"); - }} - className="flex min-h-0 flex-1 flex-col" - > - - {cloudContent} - - - {currentFilesystemTab === "local" ? localContent : null} - - - ) : ( - cloudContent - )} - - {versionDocId !== null && ( - { - if (!open) setVersionDocId(null); - }} - documentId={versionDocId} - /> - )} - - {isElectron && ( - { - setFolderWatchOpen(nextOpen); - if (!nextOpen) setWatchInitialFolder(null); - }} - searchSpaceId={searchSpaceId} - initialFolder={watchInitialFolder} - onSuccess={refreshWatchedIds} - /> - )} - { - setLocalTrustDialogOpen(nextOpen); - if (!nextOpen) setPendingLocalPath(null); - }} - > - - - Trust this workspace? - - Local mode can read and edit files inside the folders you select. Continue only if you - trust this workspace and its contents. - - {pendingLocalPath && ( - - Folder path: {pendingLocalPath} - - )} - - - Cancel - { - try { - window.localStorage.setItem(LOCAL_FILESYSTEM_TRUST_KEY, "true"); - } catch {} - setLocalTrustDialogOpen(false); - const path = pendingLocalPath; - setPendingLocalPath(null); - if (path) { - await applyLocalRootPath(path); - } else { - await runPickLocalRoot(); - } - }} - > - I trust this workspace - - - - - - - - - - !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} - > - - - - Delete {deletableSelectedIds.length} document - {deletableSelectedIds.length !== 1 ? "s" : ""}? - - - This action cannot be undone.{" "} - {deletableSelectedIds.length === 1 - ? "This document" - : `These ${deletableSelectedIds.length} documents`}{" "} - will be permanently deleted from your search space. - - - - Cancel - { - e.preventDefault(); - handleBulkDeleteSelected(); - }} - disabled={isBulkDeleting} - className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - {isBulkDeleting && } - - - - - - { - if (!open) { - setExportWarningOpen(false); - setExportWarningContext(null); - } - }} - > - - - Some documents are still processing - - {exportWarningContext?.pendingCount} document - {exportWarningContext?.pendingCount !== 1 ? "s are" : " is"} currently being processed - and will be excluded from the export. Do you want to continue? - - - - Cancel - - Export anyway - - - - - - ); - if (embedded) { return ( <> @@ -1574,113 +1057,110 @@ function AuthenticatedDocumentsSidebarBase({ {renderDocumentTree()} + {versionDocId !== null && ( + { + if (!open) setVersionDocId(null); + }} + documentId={versionDocId} + /> + )} + + + + + !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} + > + + + + Delete {deletableSelectedIds.length} document + {deletableSelectedIds.length !== 1 ? "s" : ""}? + + + This action cannot be undone.{" "} + {deletableSelectedIds.length === 1 + ? "This document" + : `These ${deletableSelectedIds.length} documents`}{" "} + will be permanently deleted from your search space. + + + + Cancel + { + e.preventDefault(); + handleBulkDeleteSelected(); + }} + disabled={isBulkDeleting} + className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + {isBulkDeleting && } + + + + + + { + if (!open) { + setExportWarningOpen(false); + setExportWarningContext(null); + } + }} + > + + + Some documents are still processing + + {exportWarningContext?.pendingCount} document + {exportWarningContext?.pendingCount !== 1 ? "s are" : " is"} currently being + processed and will be excluded from the export. Do you want to continue? + + + + Cancel + + Export anyway + + + + ); } - if (isDocked && open && !isMobile) { - return ( - - ); - } - - return ( - - {documentsContent} - - ); + return null; } // --------------------------------------------------------------------------- // Anonymous Documents Sidebar // --------------------------------------------------------------------------- -const ANON_ALLOWED_EXTENSIONS = new Set([ - ".md", - ".markdown", - ".txt", - ".text", - ".json", - ".jsonl", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", - ".conf", - ".xml", - ".css", - ".scss", - ".py", - ".js", - ".jsx", - ".ts", - ".tsx", - ".java", - ".kt", - ".go", - ".rs", - ".rb", - ".php", - ".c", - ".h", - ".cpp", - ".hpp", - ".cs", - ".swift", - ".sh", - ".sql", - ".log", - ".rst", - ".tex", - ".vue", - ".svelte", - ".astro", - ".tf", - ".proto", - ".csv", - ".tsv", - ".html", - ".htm", - ".xhtml", -]); - -const ANON_ACCEPT = Array.from(ANON_ALLOWED_EXTENSIONS).join(","); - -function AnonymousDocumentsSidebar({ - open, - onOpenChange, - isDocked = false, - onDockedChange, - embedded = false, - headerAction, -}: DocumentsSidebarProps) { +function AnonymousDocumentsSidebar({ embedded = false }: DocumentsSidebarProps) { const t = useTranslations("documents"); - const tSidebar = useTranslations("sidebar"); - const isMobile = !useMediaQuery("(min-width: 640px)"); const anonMode = useAnonymousMode(); const { gate } = useLoginGate(); - const fileInputRef = useRef(null); - const [isUploading, setIsUploading] = useState(false); - const [search, setSearch] = useState(""); - const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom); const mentionedDocKeys = useMemo( () => new Set(sidebarDocs.map((d) => getMentionDocKey(d))), @@ -1713,51 +1193,6 @@ function AnonymousDocumentsSidebar({ const uploadedDoc = anonMode.isAnonymous ? anonMode.uploadedDoc : null; const hasDoc = uploadedDoc !== null; - const handleAnonUploadClick = useCallback(() => { - if (hasDoc) { - gate("upload more documents"); - return; - } - fileInputRef.current?.click(); - }, [hasDoc, gate]); - - const handleFileChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - e.target.value = ""; - - const ext = `.${file.name.split(".").pop()?.toLowerCase()}`; - if (!ANON_ALLOWED_EXTENSIONS.has(ext)) { - gate("upload PDFs, Word documents, images, and more"); - return; - } - - setIsUploading(true); - try { - const result = await anonymousChatApiService.uploadDocument(file); - if (!result.ok) { - if (result.reason === "quota_exceeded") gate("upload more documents"); - return; - } - const data = result.data; - if (anonMode.isAnonymous) { - anonMode.setUploadedDoc({ - filename: data.filename, - sizeBytes: data.size_bytes, - }); - } - toast.success(`Uploaded "${data.filename}"`); - } catch (err) { - console.error("Upload failed:", err); - toast.error(err instanceof Error ? err.message : "Upload failed"); - } finally { - setIsUploading(false); - } - }, - [gate, anonMode] - ); - const handleRemoveDoc = useCallback(() => { if (anonMode.isAnonymous) { anonMode.setUploadedDoc(null); @@ -1777,176 +1212,6 @@ function AnonymousDocumentsSidebar({ ]; }, [anonMode]); - const searchFilteredDocs = useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return treeDocuments; - return treeDocuments.filter((d) => d.title.toLowerCase().includes(q)); - }, [treeDocuments, search]); - - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open && isMobile) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange, isMobile]); - - const documentsContent = ( - <> - - - {/* Header */} -
-
-
-

{t("title") || "Documents"}

-
-
- {isMobile && ( - - )} - {!isMobile && onDockedChange && ( - - - - - - {isDocked ? "Collapse panel" : "Expand panel"} - - - )} - {headerAction} -
-
-
- - {/* Filters & upload */} -
-
- {}} - activeTypes={[]} - onCreateFolder={() => gate("create folders")} - /> -
- -
- {}} - mentionedDocKeys={mentionedDocKeys} - onToggleChatMention={handleToggleChatMention} - onToggleFolderSelect={() => {}} - onRenameFolder={() => gate("rename folders")} - onDeleteFolder={() => gate("delete folders")} - onMoveFolder={() => gate("organize folders")} - onCreateFolder={() => gate("create folders")} - searchQuery={search.trim() || undefined} - onPreviewDocument={() => gate("preview documents")} - onEditDocument={() => gate("edit documents")} - onDeleteDocument={async () => { - handleRemoveDoc(); - setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1)); - return true; - }} - onMoveDocument={() => gate("organize documents")} - onExportDocument={() => gate("export documents")} - onVersionHistory={() => gate("view version history")} - activeTypes={[]} - onDropIntoFolder={async () => gate("organize documents")} - onReorderFolder={async () => gate("organize folders")} - watchedFolderIds={new Set()} - onRescanFolder={() => gate("watch local folders")} - onStopWatchingFolder={() => gate("watch local folders")} - onExportFolder={() => gate("export folders")} - /> - - {!hasDoc && ( -
- -

- Text, code, CSV, and HTML files only. Create an account for PDFs, images, and 30+ - connectors. -

-
- )} -
-
- - {/* CTA footer */} -
-
- - Create an account to unlock: -
-
    -
  • - PDF, Word, images, audio uploads -
  • -
  • - Unlimited documents -
  • -
- -
- - ); - if (embedded) { return ( - {documentsContent} - - ); - } - - if (isMobile) { - return ( - - - {t("title") || "Documents"} - -
{documentsContent}
-
-
- ); - } - - return ( - - {documentsContent} - - ); + return null; } diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 40a563ab2..22305e014 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -28,6 +28,10 @@ interface MobileSidebarProps { onChatArchive?: (chat: ChatItem) => void; onViewAllChats?: () => void; isAllChatsActive?: boolean; + documentsPanel?: { + open: boolean; + onOpenChange: (open: boolean) => void; + }; user: User; onSettings?: () => void; onManageMembers?: () => void; @@ -76,6 +80,7 @@ export function MobileSidebar({ onChatArchive, onViewAllChats, isAllChatsActive = false, + documentsPanel, user, onSettings, onManageMembers, @@ -94,6 +99,9 @@ export function MobileSidebar({ const handleNavItemClick = (item: NavItem) => { onNavItemClick?.(item); + if (item.url === "#documents") { + return; + } onOpenChange(false); }; @@ -167,6 +175,7 @@ export function MobileSidebar({ : undefined } isAllChatsActive={isAllChatsActive} + documentsPanel={documentsPanel} user={user} onSettings={ onSettings diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 6477e27a8..2e784298c 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -337,11 +337,7 @@ export function Sidebar({
{documentsPanel?.open ? (
- +
) : null}
From 192b9840f249364bf7ee33320a3accb548127745 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:05:11 +0530 Subject: [PATCH 17/26] refactor(sidebar): enhance layout and styling in FolderTreeView and AllChatsSidebar - Removed unnecessary min-height and overflow properties in FolderTreeView and DocumentsSidebar for cleaner layout. - Updated AllChatsSidebar to improve header styling and adjust padding for better visual consistency. - Enhanced input and button sizes for improved usability across chat components. --- .../components/documents/FolderTreeView.tsx | 2 +- .../layout/ui/sidebar/AllChatsSidebar.tsx | 25 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 6 +- .../components/layout/ui/sidebar/Sidebar.tsx | 236 +++++++++--------- 4 files changed, 137 insertions(+), 132 deletions(-) diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index ed06abcee..78a8afa43 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -336,7 +336,7 @@ export function FolderTreeView({ return ( -
{treeNodes}
+
{treeNodes}
); } diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 93bd51894..db528b13a 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -11,7 +11,6 @@ import { RotateCcwIcon, Search, Trash2, - User, Users, X, } from "lucide-react"; @@ -236,8 +235,12 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { return (
-
-

{t("chats") || "Chats"}

+
+
+

+ {t("chats") || "Chats"} +

+
{!isSearchMode && ( @@ -276,22 +279,22 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) {
- + setSearchQuery(e.target.value)} - className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none" + className="h-12 border-0 bg-muted pl-10 pr-9 text-base shadow-none" /> {searchQuery && ( )} @@ -487,17 +490,11 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) {
) : (
- -

+

{showArchived ? t("no_archived_chats") || "No archived chats" : t("no_chats") || "No chats"}

- {!showArchived && ( -

- {t("start_new_chat_hint") || "Start a new chat from the chat page"} -

- )}
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 60cb6659f..1a241023b 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -176,7 +176,7 @@ function CloudDocumentsSkeleton() { ]; return ( -
+
{rows.map((row) => (
@@ -971,7 +971,7 @@ function AuthenticatedDocumentsSidebarBase({ activeTypesForTree?: DocumentTypeEnum[]; searchQuery?: string; } = {}) => ( -
+
{deletableSelectedIds.length > 0 && (
-
+
- {inboxItem && ( - onNavItemClick?.(inboxItem)} - isCollapsed={isCollapsed} - isActive={inboxItem.isActive} - badge={inboxItem.badge} - collapsedIconNode={} - tooltipContent={isCollapsed ? inboxItem.title : undefined} - buttonProps={ - { - "data-joyride": "inbox-sidebar", - } as React.ButtonHTMLAttributes - } - /> - )} - {automationsItem && ( - onNavItemClick?.(automationsItem)} - isCollapsed={isCollapsed} - isActive={automationsItem.isActive} - tooltipContent={isCollapsed ? automationsItem.title : undefined} - /> - )} - {artifactsItem && ( - onNavItemClick?.(artifactsItem)} - isCollapsed={isCollapsed} - isActive={artifactsItem.isActive} - tooltipContent={isCollapsed ? artifactsItem.title : undefined} - /> - )} - {documentsItem && ( - onNavItemClick?.(documentsItem)} - isCollapsed={isCollapsed} - isActive={documentsItem.isActive} - tooltipContent={isCollapsed ? documentsItem.title : undefined} - /> - )}
- {/* Chat sections - fills available space */} - {isCollapsed ? ( -
- ) : ( -
- - {!disableTooltips && isAllChatsActive ? t("hide") : t("show_all")} - - ) : undefined - } - > - {isLoadingChats ? ( - - ) : chats.length > 0 ? ( -
-
4 ? "pb-2" : ""}`} - > - {chats.slice(0, 20).map((chat) => ( - setOpenDropdownChatId(open ? chat.id : null)} - onClick={() => onChatSelect(chat)} - onPrefetch={() => onChatPrefetch?.(chat)} - onRename={() => onChatRename?.(chat)} - onArchive={() => onChatArchive?.(chat)} - onDelete={() => onChatDelete?.(chat)} - /> - ))} -
- {/* Gradient fade indicator when more than 4 items */} - {chats.length > 4 && ( -
- )} -
- ) : ( -

{t("no_chats")}

- )} - - {documentsPanel?.open ? ( -
- -
- ) : null} +
setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)} + > +
+ {inboxItem && ( + onNavItemClick?.(inboxItem)} + isCollapsed={isCollapsed} + isActive={inboxItem.isActive} + badge={inboxItem.badge} + collapsedIconNode={} + tooltipContent={isCollapsed ? inboxItem.title : undefined} + buttonProps={ + { + "data-joyride": "inbox-sidebar", + } as React.ButtonHTMLAttributes + } + /> + )} + {automationsItem && ( + onNavItemClick?.(automationsItem)} + isCollapsed={isCollapsed} + isActive={automationsItem.isActive} + tooltipContent={isCollapsed ? automationsItem.title : undefined} + /> + )} + {artifactsItem && ( + onNavItemClick?.(artifactsItem)} + isCollapsed={isCollapsed} + isActive={artifactsItem.isActive} + tooltipContent={isCollapsed ? artifactsItem.title : undefined} + /> + )} + {documentsItem && ( + onNavItemClick?.(documentsItem)} + isCollapsed={isCollapsed} + isActive={documentsItem.isActive} + tooltipContent={isCollapsed ? documentsItem.title : undefined} + /> + )}
- )} + + {/* Chat sections - fills available space */} + {isCollapsed ? ( +
+ ) : ( +
+ + {!disableTooltips && isAllChatsActive ? t("hide") : t("show_all")} + + ) : undefined + } + > + {isLoadingChats ? ( + + ) : chats.length > 0 ? ( +
+
4 ? "pb-2" : ""}`}> + {chats.slice(0, 20).map((chat) => ( + + setOpenDropdownChatId(open ? chat.id : null) + } + onClick={() => onChatSelect(chat)} + onPrefetch={() => onChatPrefetch?.(chat)} + onRename={() => onChatRename?.(chat)} + onArchive={() => onChatArchive?.(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))} +
+ {/* Gradient fade indicator when more than 4 items */} + {chats.length > 4 && ( +
+ )} +
+ ) : ( +

{t("no_chats")}

+ )} + + {documentsPanel?.open ? ( +
+ +
+ ) : null} +
+ )} +
{/* Footer */}
From 7c9cb5abd3a6e4c4dfc6a0dc87778475fa84ce58 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:05:29 +0530 Subject: [PATCH 18/26] refactor(sidebar): update AllChatsSidebar styling and folder type validation - Enhanced padding and gap in AllChatsSidebar for improved layout consistency. - Adjusted skeleton and button sizes for better usability in chat components. - Refactored folder type validation to ensure search_space_id is set based on workspace_id when missing. --- .../layout/ui/sidebar/AllChatsSidebar.tsx | 20 +++++++++---------- surfsense_web/contracts/types/folder.types.ts | 12 ++++++++++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index db528b13a..ae5dac223 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -307,10 +307,10 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { {[75, 90, 55, 80, 65, 85].map((titleWidth) => (
- +
))}
@@ -346,10 +346,10 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { onTouchMove={longPressHandlers.onTouchMove} disabled={isBusy} className={cn( - "h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal", + "h-auto w-full justify-start gap-2.5 overflow-hidden px-3 py-2.5 text-left text-base font-normal", "group-hover/item:bg-accent group-hover/item:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", - thread.visibility === "SEARCH_SPACE" && "pr-9", + thread.visibility === "SEARCH_SPACE" && "pr-10", isActive && "bg-accent text-accent-foreground", isBusy && "opacity-50 pointer-events-none" )} @@ -367,10 +367,10 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) { onFocus={() => prefetchChatThread(thread.id)} disabled={isBusy} className={cn( - "h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal", + "h-auto w-full justify-start gap-2.5 overflow-hidden px-3 py-2.5 text-left text-base font-normal", "group-hover/item:bg-accent group-hover/item:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", - thread.visibility === "SEARCH_SPACE" && "pr-9", + thread.visibility === "SEARCH_SPACE" && "pr-10", isActive && "bg-accent text-accent-foreground", isBusy && "opacity-50 pointer-events-none" )} @@ -390,7 +390,7 @@ function AllChatsContent({ searchSpaceId, className }: AllChatsContentProps) {
-
+
{thread.visibility === "SEARCH_SPACE" ? ( ) : ( - + )} {t("more_options") || "More options"} diff --git a/surfsense_web/contracts/types/folder.types.ts b/surfsense_web/contracts/types/folder.types.ts index 8a9696a1f..0f7afd48b 100644 --- a/surfsense_web/contracts/types/folder.types.ts +++ b/surfsense_web/contracts/types/folder.types.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const folder = z.object({ +const folderBase = z.object({ id: z.number(), name: z.string(), position: z.string(), @@ -12,6 +12,16 @@ export const folder = z.object({ metadata: z.record(z.string(), z.any()).nullable().optional(), }); +export const folder = z.preprocess((value) => { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const record = value as Record; + if (record.search_space_id === undefined && record.workspace_id !== undefined) { + return { ...record, search_space_id: record.workspace_id }; + } + } + return value; +}, folderBase); + export const folderCreateRequest = z.object({ name: z.string().min(1).max(255), parent_id: z.number().nullable().optional(), From 9f853b692c8ed6944b17fcf0e6d567658ebeadaa Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:28:02 +0530 Subject: [PATCH 19/26] refactor(sidebar): update SidebarSection transition styles for improved visibility --- surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index 73cf8485f..c9d974f97 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -80,7 +80,7 @@ export function SidebarSection({
Date: Mon, 6 Jul 2026 13:52:47 +0530 Subject: [PATCH 20/26] refactor(layout): improve layout consistency and styling across user and workspace settings - Updated section classes in UserSettingsLayoutShell and WorkspaceSettingsLayoutShell for better alignment. - Adjusted padding in LayoutDataProvider for improved spacing on team and automation pages. - Enhanced AllChatsSidebar layout by removing unnecessary height constraints for a more flexible design. --- .../dashboard/[workspace_id]/user-settings/layout-shell.tsx | 2 +- .../[workspace_id]/workspace-settings/layout-shell.tsx | 2 +- .../components/layout/providers/LayoutDataProvider.tsx | 2 +- .../components/layout/ui/sidebar/AllChatsSidebar.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx index 684b7d6f4..0e33a5be5 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx @@ -122,7 +122,7 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`; return ( -
+

{t("title")}