@@ -365,7 +365,7 @@ export function AllPrivateChatsSidebar({
disabled={isBusy}
>
{isDeleting ? (
-
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index 76dbf1aad..89bdba675 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
- Loader2,
MessageCircleMore,
MoreHorizontal,
RotateCcwIcon,
@@ -30,6 +29,7 @@ import {
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { Spinner } from "@/components/ui/spinner";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
deleteThread,
@@ -304,7 +304,7 @@ export function AllSharedChatsSidebar({
{isLoading ? (
-
+
) : error ? (
@@ -365,7 +365,7 @@ export function AllSharedChatsSidebar({
disabled={isBusy}
>
{isDeleting ? (
-
+
) : (
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
index 5dd9c2cfa..6db6782d0 100644
--- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
@@ -1,12 +1,13 @@
"use client";
-import { MessageSquare, MoreHorizontal } from "lucide-react";
+import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
@@ -14,11 +15,20 @@ import { cn } from "@/lib/utils";
interface ChatListItemProps {
name: string;
isActive?: boolean;
+ archived?: boolean;
onClick?: () => void;
+ onArchive?: () => void;
onDelete?: () => void;
}
-export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) {
+export function ChatListItem({
+ name,
+ isActive,
+ archived,
+ onClick,
+ onArchive,
+ onDelete,
+}: ChatListItemProps) {
const t = useTranslations("sidebar");
return (
@@ -48,15 +58,39 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
- {
- e.stopPropagation();
- onDelete?.();
- }}
- className="text-destructive focus:text-destructive"
- >
- {t("delete")}
-
+ {onArchive && (
+ {
+ e.stopPropagation();
+ onArchive();
+ }}
+ >
+ {archived ? (
+ <>
+
+ {t("unarchive") || "Restore"}
+ >
+ ) : (
+ <>
+
+ {t("archive") || "Archive"}
+ >
+ )}
+
+ )}
+ {onArchive && onDelete && }
+ {onDelete && (
+ {
+ e.stopPropagation();
+ onDelete();
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {t("delete")}
+
+ )}
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index 7dd01d75a..85f907611 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -25,6 +25,7 @@ interface MobileSidebarProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
+ onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@@ -64,6 +65,7 @@ export function MobileSidebar({
onNewChat,
onChatSelect,
onChatDelete,
+ onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@@ -141,6 +143,7 @@ export function MobileSidebar({
}}
onChatSelect={handleChatSelect}
onChatDelete={onChatDelete}
+ onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}
diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
index 742a27bbc..8bf83ac11 100644
--- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
@@ -20,7 +20,9 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents")
? { "data-joyride": "documents-sidebar" }
- : {};
+ : item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
+ ? { "data-joyride": "inbox-sidebar" }
+ : {};
if (isCollapsed) {
return (
@@ -32,8 +34,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
className={cn(
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
- item.isActive && "bg-accent text-accent-foreground"
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
{...joyrideAttr}
>
@@ -62,8 +63,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
className={cn(
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
- item.isActive && "bg-accent text-accent-foreground"
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
{...joyrideAttr}
>
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index f3452749f..d05f21096 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -27,6 +27,7 @@ interface SidebarProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
+ onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@@ -52,6 +53,7 @@ export function Sidebar({
onNewChat,
onChatSelect,
onChatDelete,
+ onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@@ -175,7 +177,9 @@ export function Sidebar({
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
+ archived={chat.archived}
onClick={() => onChatSelect(chat)}
+ onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
@@ -216,7 +220,9 @@ export function Sidebar({
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
+ archived={chat.archived}
onClick={() => onChatSelect(chat)}
+ onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
index 5c8c89152..28c359e64 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
+import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@@ -57,7 +57,7 @@ export function SidebarHeader({
{t("manage_members")}
router.push(`/dashboard/${searchSpaceId}/logs`)}>
-
+
{t("logs")}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
index 982d88e8b..7c96b1dcb 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
+import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
@@ -197,11 +197,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
- isSelected && "bg-accent/80"
+ isSelected && "text-primary"
)}
>
{t(themeOption.value)}
+ {isSelected &&
}
);
})}
@@ -226,11 +227,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
- isSelected && "bg-accent/80"
+ isSelected && "text-primary"
)}
>
{language.flag}
{language.name}
+ {isSelected &&
}
);
})}
@@ -313,11 +315,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
- isSelected && "bg-accent/80"
+ isSelected && "text-primary"
)}
>
{t(themeOption.value)}
+ {isSelected &&
}
);
})}
@@ -342,11 +345,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
- isSelected && "bg-accent/80"
+ isSelected && "text-primary"
)}
>
{language.flag}
{language.name}
+ {isSelected &&
}
);
})}
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index bc7591c51..515fe9f32 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -8,7 +8,6 @@ import {
Cloud,
Edit3,
Globe,
- Loader2,
Plus,
Settings2,
Sparkles,
@@ -36,6 +35,7 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Spinner } from "@/components/ui/spinner";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
@@ -179,7 +179,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
>
{isLoading ? (
<>
-
+
Loading
>
) : currentConfig ? (
diff --git a/surfsense_web/components/new-chat/source-detail-panel.tsx b/surfsense_web/components/new-chat/source-detail-panel.tsx
index 08cff8380..087d9b775 100644
--- a/surfsense_web/components/new-chat/source-detail-panel.tsx
+++ b/surfsense_web/components/new-chat/source-detail-panel.tsx
@@ -1,17 +1,9 @@
"use client";
import { useQuery } from "@tanstack/react-query";
-import {
- BookOpen,
- ChevronDown,
- ExternalLink,
- FileText,
- Hash,
- Loader2,
- Sparkles,
- X,
-} from "lucide-react";
+import { BookOpen, ChevronDown, ExternalLink, FileText, Hash, Sparkles, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { useTranslations } from "next-intl";
import type React from "react";
import { forwardRef, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
@@ -20,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
+import { Spinner } from "@/components/ui/spinner";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
@@ -63,7 +56,7 @@ interface ChunkCardProps {
}
const ChunkCard = forwardRef
(
- ({ chunk, index, totalChunks, isCited, isActive, disableLayoutAnimation }, ref) => {
+ ({ chunk, index, totalChunks, isCited }, ref) => {
return (
(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
const [summaryOpen, setSummaryOpen] = useState(false);
const [activeChunkIndex, setActiveChunkIndex] = useState
(null);
const [mounted, setMounted] = useState(false);
- const [hasScrolledToCited, setHasScrolledToCited] = useState(false);
+ const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
@@ -382,11 +376,10 @@ export function SourceDetailPanel({
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
-
- Loading document
+
+
+ {t("loading_document")}
+
)}
diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx
index 717a27607..12773c932 100644
--- a/surfsense_web/components/onboarding-tour.tsx
+++ b/surfsense_web/components/onboarding-tour.tsx
@@ -32,6 +32,12 @@ const TOUR_STEPS: TourStep[] = [
content: "Access and manage all your uploaded documents.",
placement: "right",
},
+ {
+ target: '[data-joyride="inbox-sidebar"]',
+ title: "Check your inbox",
+ content: "View mentions and notifications in one place.",
+ placement: "right",
+ },
];
interface TooltipPosition {
@@ -188,14 +194,15 @@ function TourTooltip({
const getPointerStyles = (): React.CSSProperties => {
const lineLength = 16;
const dotSize = 6;
- // Check if this is the documents step (stepIndex === 1)
+ // Check if this is the documents step (stepIndex === 1) or inbox step (stepIndex === 2)
const isDocumentsStep = stepIndex === 1;
+ const isInboxStep = stepIndex === 2;
if (position.pointerPosition === "left") {
return {
position: "absolute",
left: -lineLength - dotSize,
- top: isDocumentsStep ? "calc(50% - 8px)" : "50%",
+ top: isDocumentsStep || isInboxStep ? "calc(50% - 8px)" : "50%",
transform: "translateY(-50%)",
display: "flex",
alignItems: "center",
@@ -518,12 +525,13 @@ export function OnboardingTour() {
// User is new and hasn't seen tour - wait for DOM elements and start tour
const checkAndStartTour = () => {
- // Check if both required elements exist
+ // Check if all required elements exist
const connectorEl = document.querySelector(TOUR_STEPS[0].target);
const documentsEl = document.querySelector(TOUR_STEPS[1].target);
+ const inboxEl = document.querySelector(TOUR_STEPS[2].target);
- if (connectorEl && documentsEl) {
- // Both elements found, start tour
+ if (connectorEl && documentsEl && inboxEl) {
+ // All elements found, start tour
setIsActive(true);
setTargetEl(connectorEl);
setSpotlightTargetEl(connectorEl);
diff --git a/surfsense_web/components/providers/ElectricProvider.tsx b/surfsense_web/components/providers/ElectricProvider.tsx
index e31885973..07d736c64 100644
--- a/surfsense_web/components/providers/ElectricProvider.tsx
+++ b/surfsense_web/components/providers/ElectricProvider.tsx
@@ -1,8 +1,11 @@
"use client";
import { useAtomValue } from "jotai";
+import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
+import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
+import { getBearerToken } from "@/lib/auth-utils";
import {
cleanupElectric,
type ElectricClient,
@@ -27,6 +30,7 @@ interface ElectricProviderProps {
* 5. Provides client via context - hooks should use useElectricClient()
*/
export function ElectricProvider({ children }: ElectricProviderProps) {
+ const t = useTranslations("common");
const [electricClient, setElectricClient] = useState(null);
const [error, setError] = useState(null);
const {
@@ -105,21 +109,25 @@ export function ElectricProvider({ children }: ElectricProviderProps) {
};
}, [user?.id, isUserLoaded, electricClient]);
+ // Check if user is authenticated first (has bearer token)
+ // This prevents showing loading screen for unauthenticated users on homepage
+ const hasToken = typeof window !== "undefined" && !!getBearerToken();
+
+ // Determine if we should show loading
+ const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error;
+
+ // Use global loading hook with ownership tracking - prevents flash during transitions
+ useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default");
+
// For non-authenticated pages (like landing page), render immediately with null context
// Also render immediately if user query failed (e.g., token expired)
- if (!isUserLoaded || !user?.id || isUserError) {
+ if (!hasToken || !isUserLoaded || !user?.id || isUserError) {
return {children} ;
}
- // Show loading state while initializing for authenticated users
+ // Return children with null context while initializing - the global provider handles the loading UI
if (!electricClient && !error) {
- return (
-
-
-
- );
+ return {children} ;
}
// If there's an error, still render but warn
diff --git a/surfsense_web/components/providers/GlobalLoadingProvider.tsx b/surfsense_web/components/providers/GlobalLoadingProvider.tsx
new file mode 100644
index 000000000..e72f2b876
--- /dev/null
+++ b/surfsense_web/components/providers/GlobalLoadingProvider.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
+import { Logo } from "@/components/Logo";
+import { Spinner } from "@/components/ui/spinner";
+import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
+import { cn } from "@/lib/utils";
+
+/**
+ * GlobalLoadingProvider renders a persistent loading overlay.
+ * The spinner is ALWAYS in the DOM to prevent animation reset when
+ * loading states change between different pages/components.
+ *
+ * Visibility is controlled via CSS opacity/pointer-events, NOT mounting/unmounting.
+ */
+export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) {
+ const [mounted, setMounted] = useState(false);
+ const { isLoading, message, variant } = useAtomValue(globalLoadingAtom);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // The overlay is ALWAYS rendered, but visibility is controlled by CSS
+ // This prevents the spinner animation from resetting
+ const loadingOverlay = (
+
+ {variant === "login" ? (
+
+
+
+
+
+
+ {/* Spinner is always mounted, animation never resets */}
+
+
+
+ {message}
+
+
+
+
+ ) : (
+
+
+
+ {/* Spinner is always mounted, animation never resets */}
+
+
+
+ {message}
+
+
+
+ )}
+
+ );
+
+ // Render inline during SSR/before hydration, use portal after mounting
+ // This prevents the white flash during initial render
+ return (
+ <>
+ {children}
+ {mounted ? createPortal(loadingOverlay, document.body) : loadingOverlay}
+ >
+ );
+}
diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx
new file mode 100644
index 000000000..b2f93402a
--- /dev/null
+++ b/surfsense_web/components/settings/general-settings-manager.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { useQuery } from "@tanstack/react-query";
+import { Info, RotateCcw, Save } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+interface GeneralSettingsManagerProps {
+ searchSpaceId: number;
+}
+
+export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) {
+ const t = useTranslations("searchSpaceSettings");
+ const tCommon = useTranslations("common");
+ const {
+ data: searchSpace,
+ isLoading: loading,
+ refetch: fetchSearchSpace,
+ } = useQuery({
+ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
+ queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
+ enabled: !!searchSpaceId,
+ });
+
+ const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+
+ // Initialize state from fetched search space
+ useEffect(() => {
+ if (searchSpace) {
+ setName(searchSpace.name || "");
+ setDescription(searchSpace.description || "");
+ setHasChanges(false);
+ }
+ }, [searchSpace]);
+
+ // Track changes
+ useEffect(() => {
+ if (searchSpace) {
+ const currentName = searchSpace.name || "";
+ const currentDescription = searchSpace.description || "";
+ const changed = currentName !== name || currentDescription !== description;
+ setHasChanges(changed);
+ }
+ }, [searchSpace, name, description]);
+
+ const handleSave = async () => {
+ try {
+ setSaving(true);
+
+ await updateSearchSpace({
+ id: searchSpaceId,
+ data: {
+ name: name.trim(),
+ description: description.trim() || undefined,
+ },
+ });
+
+ setHasChanges(false);
+ await fetchSearchSpace();
+ } catch (error: any) {
+ console.error("Error saving search space details:", error);
+ toast.error(error.message || "Failed to save search space details");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleReset = () => {
+ if (searchSpace) {
+ setName(searchSpace.name || "");
+ setDescription(searchSpace.description || "");
+ setHasChanges(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Update your search space name and description. These details help identify and organize
+ your workspace.
+
+
+
+ {/* Search Space Details Card */}
+
+
+ Search Space Details
+
+ Manage the basic information for this search space.
+
+
+
+
+
+ {t("general_name_label")}
+
+
setName(e.target.value)}
+ className="text-sm md:text-base h-9 md:h-10"
+ />
+
+ {t("general_name_description")}
+
+
+
+
+
+ {t("general_description_label")}{" "}
+ ({tCommon("optional")})
+
+
setDescription(e.target.value)}
+ className="text-sm md:text-base h-9 md:h-10"
+ />
+
+ {t("general_description_description")}
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+ {t("general_reset")}
+
+
+
+ {saving ? t("general_saving") : t("general_save")}
+
+
+
+ {hasChanges && (
+
+
+
+ {t("general_unsaved_changes")}
+
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/components/settings/llm-role-manager.tsx b/surfsense_web/components/settings/llm-role-manager.tsx
index c41a2d3bf..649507d77 100644
--- a/surfsense_web/components/settings/llm-role-manager.tsx
+++ b/surfsense_web/components/settings/llm-role-manager.tsx
@@ -1,16 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
-import {
- AlertCircle,
- Bot,
- CheckCircle,
- FileText,
- Loader2,
- RefreshCw,
- RotateCcw,
- Save,
-} from "lucide-react";
+import { AlertCircle, Bot, CheckCircle, FileText, RefreshCw, RotateCcw, Save } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -32,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
const ROLE_DESCRIPTIONS = {
agent: {
@@ -206,7 +198,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
-
+
{configsLoading && preferencesLoading
? "Loading configurations and preferences..."
diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx
index 95230930c..c2769d403 100644
--- a/surfsense_web/components/settings/model-config-manager.tsx
+++ b/surfsense_web/components/settings/model-config-manager.tsx
@@ -7,7 +7,6 @@ import {
Clock,
Edit3,
FileText,
- Loader2,
MessageSquareQuote,
Plus,
RefreshCw,
@@ -49,6 +48,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { Spinner } from "@/components/ui/spinner";
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
@@ -211,7 +211,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
-
+
Loading configurations...
@@ -484,7 +484,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
>
{isDeleting ? (
<>
-
+
Deleting
>
) : (
diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx
index 5ffff1ab7..1a23eced6 100644
--- a/surfsense_web/components/shared/llm-config-form.tsx
+++ b/surfsense_web/components/shared/llm-config-form.tsx
@@ -8,7 +8,6 @@ import {
ChevronDown,
ChevronsUpDown,
Key,
- Loader2,
MessageSquareQuote,
Rocket,
Sparkles,
@@ -48,6 +47,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
+import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getModelsByProvider } from "@/contracts/enums/llm-models";
@@ -592,7 +592,7 @@ export function LLMConfigForm({
>
{isSubmitting ? (
<>
-
+
{mode === "edit" ? "Updating..." : "Creating"}
>
) : (
diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx
index bb7b0e40d..a0ccb7c5f 100644
--- a/surfsense_web/components/sources/DocumentUploadTab.tsx
+++ b/surfsense_web/components/sources/DocumentUploadTab.tsx
@@ -1,13 +1,14 @@
"use client";
import { useAtom } from "jotai";
-import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
+import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
+import { Spinner } from "@/components/ui/spinner";
import {
Accordion,
AccordionContent,
@@ -424,7 +425,7 @@ export function DocumentUploadTab({
>
{isUploading ? (
-
+
{t("uploading")}
) : (
diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx
index f9855a007..c76d7ce5a 100644
--- a/surfsense_web/components/tool-ui/generate-podcast.tsx
+++ b/surfsense_web/components/tool-ui/generate-podcast.tsx
@@ -1,10 +1,11 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
-import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
+import { AlertCircleIcon, MicIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Audio } from "@/components/tool-ui/audio";
+import { Spinner } from "@/components/ui/spinner";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
@@ -97,8 +98,8 @@ function PodcastGeneratingState({ title }: { title: string }) {
{title}
-
- Generating podcast. This may take a few minutes
+
+ Generating podcast. This may take a few minutes.
@@ -144,7 +145,7 @@ function AudioLoadingState({ title }: { title: string }) {
{title}
-
+
Loading audio...
diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx
index f872e293f..42725d258 100644
--- a/surfsense_web/components/tool-ui/image/index.tsx
+++ b/surfsense_web/components/tool-ui/image/index.tsx
@@ -1,11 +1,12 @@
"use client";
-import { ExternalLinkIcon, ImageIcon, Loader2 } from "lucide-react";
+import { ExternalLinkIcon, ImageIcon } from "lucide-react";
import NextImage from "next/image";
import { Component, type ReactNode, useState } from "react";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
+import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
/**
@@ -184,7 +185,7 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string })
diff --git a/surfsense_web/components/tool-ui/media-card/index.tsx b/surfsense_web/components/tool-ui/media-card/index.tsx
index d4fe0c7c0..c7c8cfdf2 100644
--- a/surfsense_web/components/tool-ui/media-card/index.tsx
+++ b/surfsense_web/components/tool-ui/media-card/index.tsx
@@ -1,12 +1,13 @@
"use client";
-import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } from "lucide-react";
+import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon } from "lucide-react";
import Image from "next/image";
import { Component, type ReactNode } from "react";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
+import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -299,18 +300,17 @@ export function MediaCard({
{/* Response Actions */}
{responseActions && responseActions.length > 0 && (
- e.stopPropagation()}
- onKeyDown={(e) => e.stopPropagation()}
- >
+
{responseActions.map((action) => (
onResponseAction?.(action.id)}
+ onClick={(e) => {
+ e.stopPropagation();
+ onResponseAction?.(action.id);
+ }}
>
{action.label}
@@ -337,7 +337,7 @@ export function MediaCardLoading({ title = "Loading preview..." }: { title?: str
return (
-
+
diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx
index a5da31e9e..9b959bd33 100644
--- a/surfsense_web/components/tool-ui/write-todos.tsx
+++ b/surfsense_web/components/tool-ui/write-todos.tsx
@@ -2,7 +2,6 @@
import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
-import { Loader2 } from "lucide-react";
import { useEffect, useMemo } from "react";
import { z } from "zod";
import {
@@ -11,6 +10,7 @@ import {
registerPlanOwner,
updatePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
+import { Spinner } from "@/components/ui/spinner";
import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan";
// ============================================================================
@@ -46,7 +46,7 @@ function WriteTodosLoading() {
return (
diff --git a/surfsense_web/components/ui/tooltip.tsx b/surfsense_web/components/ui/tooltip.tsx
index 871ce9ed5..356467a8a 100644
--- a/surfsense_web/components/ui/tooltip.tsx
+++ b/surfsense_web/components/ui/tooltip.tsx
@@ -7,12 +7,14 @@ import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
+ disableHoverableContent = true,
...props
}: React.ComponentProps
) {
return (
);
@@ -42,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
- "bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance",
+ "bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
className
)}
{...props}
diff --git a/surfsense_web/content/docs/connectors/airtable.mdx b/surfsense_web/content/docs/connectors/airtable.mdx
index 366a6e8e5..db7fe3ac0 100644
--- a/surfsense_web/content/docs/connectors/airtable.mdx
+++ b/surfsense_web/content/docs/connectors/airtable.mdx
@@ -36,20 +36,20 @@ After registration, configure the required scopes (permissions) for your integra
| Scope | Description |
|-------|-------------|
-| ✅ `data.recordComments:read` | See comments in records |
-| ✅ `data.records:read` | See the data in records |
+| `data.recordComments:read` | See comments in records |
+| `data.records:read` | See the data in records |
### Base schema
| Scope | Description |
|-------|-------------|
-| ✅ `schema.bases:read` | See the structure of a base, like table names or field types |
+| `schema.bases:read` | See the structure of a base, like table names or field types |
### User metadata
| Scope | Description |
|-------|-------------|
-| ✅ `user.email:read` | See the user's email address |
+| `user.email:read` | See the user's email address |

@@ -80,7 +80,9 @@ After saving, you'll find your OAuth credentials on the integration page:
1. Copy your **Client ID**
2. Copy your **Client Secret**
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+
---
diff --git a/surfsense_web/content/docs/connectors/bookstack.mdx b/surfsense_web/content/docs/connectors/bookstack.mdx
index 8ee581948..f1f695e11 100644
--- a/surfsense_web/content/docs/connectors/bookstack.mdx
+++ b/surfsense_web/content/docs/connectors/bookstack.mdx
@@ -3,4 +3,70 @@ title: Bookstack
description: Connect your Bookstack instance to SurfSense
---
-# Documentation in progress
\ No newline at end of file
+# BookStack Integration Setup Guide
+
+This guide walks you through connecting your BookStack instance to SurfSense.
+
+## How it works
+
+The BookStack connector uses the BookStack REST API to fetch all pages from your BookStack instance that your account has access to.
+
+- For follow up indexing runs, the connector retrieves pages that have been updated since the last indexing attempt.
+- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
+
+---
+
+## Authorization
+
+
+You need to create an API token from your BookStack instance. The token requires "Access System API" permission.
+
+
+### Step 1: Create an API Token
+
+1. Log in to your BookStack instance
+2. Click on your profile icon → Edit Profile
+3. Navigate to the "API Tokens" tab
+4. Click "Create Token" and give it a name
+5. Copy both the **Token ID** and **Token Secret**
+6. Paste them in the connector form
+
+### Step 2: Grant necessary access
+
+Your user account must have "Access System API" permission. The connector will only index content your account can view.
+
+
+BookStack API has a rate limit of 180 requests per minute. The connector automatically handles rate limiting to ensure reliable indexing.
+
+
+
+Enable periodic sync to automatically re-index pages when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
+
+
+---
+
+## Connecting to SurfSense
+
+1. Navigate to the Connector Dashboard and select the **BookStack** Connector.
+2. Fill in the required fields:
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| **Connector Name** | A friendly name to identify this connector | `My BookStack Connector` |
+| **BookStack Base URL** | The full URL of your BookStack instance | `https://docs.example.com` |
+| **Token ID** | Your API Token ID from BookStack | |
+| **Token Secret** | Your API Token Secret from BookStack | |
+
+3. Click **Connect** to establish the connection.
+4. Once connected, your BookStack pages will be indexed automatically.
+
+### What Gets Indexed
+
+The BookStack connector indexes the following data:
+
+| Data Type | Description |
+|-----------|-------------|
+| Pages | All pages from your BookStack instance |
+| Content | Page content in Markdown format |
+| Metadata | Page titles and metadata |
+| Structure | Book and chapter hierarchy information |
diff --git a/surfsense_web/content/docs/connectors/circleback.mdx b/surfsense_web/content/docs/connectors/circleback.mdx
index a5c90a28f..709e35f45 100644
--- a/surfsense_web/content/docs/connectors/circleback.mdx
+++ b/surfsense_web/content/docs/connectors/circleback.mdx
@@ -1,8 +1,133 @@
---
title: Circleback
-description: Connect your circleback to SurfSense
+description: Connect your Circleback meetings to SurfSense
---
-# Documentation in progress
+# Circleback Integration Setup Guide
+This guide walks you through connecting your Circleback meetings to SurfSense for meeting search and AI-powered insights.
+## How it works
+
+The Circleback connector uses a **webhook-based integration**. Unlike other connectors that pull data from APIs, Circleback automatically pushes meeting data to SurfSense when meetings are processed.
+
+- Meetings are sent to SurfSense immediately after Circleback processes them
+- No API keys or credentials required
+- No periodic indexing needed - data arrives in real-time
+- Each meeting is stored with its notes, transcript, action items, and insights
+
+---
+
+## What Gets Indexed
+
+| Content Type | Description |
+|--------------|-------------|
+| Meeting Notes | AI-generated meeting notes in Markdown format |
+| Transcript | Full meeting transcript with speaker identification and timestamps |
+| Action Items | Tasks with assignees and status |
+| Attendees | Names and emails of meeting participants |
+| Insights | Custom insights extracted from the meeting |
+| Metadata | Date, duration, tags, meeting URL, and Circleback link |
+
+
+ Meeting recordings are not stored in SurfSense, but a link to the recording (valid for 24 hours from Circleback) is included in the indexed document.
+
+
+---
+
+## Setup Guide
+
+### Step 1: Create the Connector in SurfSense
+
+1. Navigate to **Connectors** → **Add Connector** → **Circleback**
+2. Enter a connector name (e.g., `My Circleback Meetings`)
+3. Click **Connect** to create the connector
+
+
+Circleback uses webhooks, so no API key or authentication is required. The webhook URL is unique to your search space.
+
+
+### Step 2: Copy Your Webhook URL
+
+After creating the connector:
+
+1. Open the connector settings
+2. Find the **Webhook URL** field
+3. Click **Copy** to copy the URL to your clipboard
+
+The webhook URL looks like:
+```
+https://your-surfsense-url/api/v1/webhooks/circleback/{search_space_id}
+```
+
+### Step 3: Configure Circleback Automation
+
+1. Log into your [Circleback account](https://app.circleback.ai)
+2. Go to **Automations** (in the main navigation)
+3. Click **Create automation**
+4. Add conditions to filter which meetings trigger the webhook (optional):
+ - Specific calendar(s)
+ - Meeting tags
+ - Meeting participants
+5. Select **Send webhook request** as the action
+6. Enter your SurfSense webhook URL
+7. Select the meeting outcomes to include (notes, action items, transcript, insights)
+8. Optionally test with your most recent meeting
+9. Name and save the automation
+
+For detailed instructions, see [Circleback's official webhook documentation](https://circleback.ai/docs/webhook-integration).
+
+
+ Make sure your SurfSense backend is accessible from the internet for Circleback to send webhooks. If self-hosting, you may need to configure a reverse proxy or use a tunneling service.
+
+
+---
+
+## Connector Configuration
+
+| Field | Description | Required |
+|-------|-------------|----------|
+| **Connector Name** | A friendly name to identify this connector | Yes |
+
+
+ Unlike other connectors, Circleback doesn't support periodic indexing since it's webhook-based. Meetings are automatically pushed to SurfSense when processed by Circleback.
+
+
+---
+
+## Verifying the Integration
+
+Once configured, new meetings will automatically appear in SurfSense after Circleback processes them. To verify:
+
+1. Attend or process a meeting with Circleback
+2. Wait for Circleback to complete processing (usually a few minutes after the meeting ends)
+3. Check your SurfSense search space for the new meeting document
+
+Each meeting document includes:
+- A direct link to view the meeting on Circleback
+- Full searchable transcript
+- Extracted action items with assignees
+- Meeting metadata and insights
+
+---
+
+## Troubleshooting
+
+**Meetings not appearing**
+- Verify the webhook URL is correctly configured in Circleback
+- Check that your SurfSense backend is accessible from the internet
+- Look for errors in your SurfSense backend logs
+
+**Webhook URL not showing**
+- Refresh the connector configuration page
+- Ensure the connector was created successfully
+- Check that your backend is running
+
+**Duplicate meetings**
+- Each meeting is uniquely identified by its Circleback meeting ID
+- If content changes, the existing document is updated rather than duplicated
+
+**Delayed indexing**
+- Meeting data is processed asynchronously
+- Documents should appear within a minute of receiving the webhook
+- Check the task queue if experiencing longer delays
diff --git a/surfsense_web/content/docs/connectors/clickup.mdx b/surfsense_web/content/docs/connectors/clickup.mdx
index 1b732c968..960b88370 100644
--- a/surfsense_web/content/docs/connectors/clickup.mdx
+++ b/surfsense_web/content/docs/connectors/clickup.mdx
@@ -36,7 +36,9 @@ After creating the app, you'll see your credentials:
1. Copy your **Client ID**
2. Copy your **Client Secret** (click "Show" to reveal, or "Regenerate" if needed)
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+
---
diff --git a/surfsense_web/content/docs/connectors/confluence.mdx b/surfsense_web/content/docs/connectors/confluence.mdx
index fad9f3e3d..57116cf29 100644
--- a/surfsense_web/content/docs/connectors/confluence.mdx
+++ b/surfsense_web/content/docs/connectors/confluence.mdx
@@ -28,7 +28,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
2. Check the box to agree to Atlassian's developer terms
3. Click **"Create"**
-> ℹ️ New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
+
+ New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
+

@@ -41,7 +43,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
```
3. Click **"Save changes"**
-> ℹ️ You can enter up to 10 redirect URIs, one per line.
+
+ You can enter up to 10 redirect URIs, one per line.
+

@@ -62,7 +66,7 @@ Select the **"Classic scopes"** tab and enable:
| Scope Name | Code | Description |
|------------|------|-------------|
-| ✅ Read user | `read:confluence-user` | View user information in Confluence that you have access to, including usernames, email addresses and profile pictures |
+| Read user | `read:confluence-user` | View user information in Confluence that you have access to, including usernames, email addresses and profile pictures |

@@ -72,9 +76,9 @@ Select the **"Granular scopes"** tab and enable:
| Scope Name | Code | Description |
|------------|------|-------------|
-| ✅ View pages | `read:page:confluence` | View page content |
-| ✅ View comments | `read:comment:confluence` | View comments on pages or blogposts |
-| ✅ View spaces | `read:space:confluence` | View space details |
+| View pages | `read:page:confluence` | View page content |
+| View comments | `read:comment:confluence` | View comments on pages or blogposts |
+| View spaces | `read:space:confluence` | View space details |
4. Click **"Save"**
@@ -85,7 +89,9 @@ Select the **"Granular scopes"** tab and enable:
1. In the left sidebar, click **"Settings"**
2. Copy your **Client ID** and **Client Secret**
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+
---
diff --git a/surfsense_web/content/docs/connectors/discord.mdx b/surfsense_web/content/docs/connectors/discord.mdx
index 6bb64e7e7..a90bcfe87 100644
--- a/surfsense_web/content/docs/connectors/discord.mdx
+++ b/surfsense_web/content/docs/connectors/discord.mdx
@@ -38,7 +38,9 @@ You'll also see your **Application ID** and **Public Key** on this page.
http://localhost:8000/api/v1/auth/discord/connector/callback
```
-> ⚠️ Keep **Public Client** disabled (off) since SurfSense uses a server to make requests.
+
+ Keep **Public Client** disabled (off) since SurfSense uses a server to make requests.
+

@@ -46,13 +48,15 @@ You'll also see your **Application ID** and **Public Key** on this page.
1. In the left sidebar, click **"Bot"**
2. Configure the **Authorization Flow**:
- - ✅ **Public Bot** - Enable to allow anyone to add the bot to servers
+ - **Public Bot** - Enable to allow anyone to add the bot to servers
3. Enable **Privileged Gateway Intents**:
- - ✅ **Server Members Intent** - Required to receive GUILD_MEMBERS events
- - ✅ **Message Content Intent** - Required to receive message content
+ - **Server Members Intent** - Required to receive GUILD_MEMBERS events
+ - **Message Content Intent** - Required to receive message content
-> ⚠️ Once your bot reaches 100+ servers, these intents will require verification and approval.
+
+ Once your bot reaches 100+ servers, these intents will require verification and approval.
+

diff --git a/surfsense_web/content/docs/connectors/elasticsearch.mdx b/surfsense_web/content/docs/connectors/elasticsearch.mdx
index ac43cca4e..f2615a9c6 100644
--- a/surfsense_web/content/docs/connectors/elasticsearch.mdx
+++ b/surfsense_web/content/docs/connectors/elasticsearch.mdx
@@ -3,4 +3,115 @@ title: Elasticsearch
description: Connect your Elasticsearch cluster to SurfSense
---
-# Documentation in progress
\ No newline at end of file
+# Elasticsearch Integration Setup Guide
+
+This guide walks you through connecting your Elasticsearch cluster to SurfSense.
+
+## How it works
+
+The Elasticsearch connector allows you to search and retrieve documents from your Elasticsearch cluster. Configure connection details, select specific indices, and set search parameters to make your existing data searchable within SurfSense.
+
+- For follow-up indexing runs, the connector retrieves documents that have been updated since the last indexing attempt.
+- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
+
+---
+
+## Authorization
+
+
+Elasticsearch requires authentication. You can use either an API key or username/password authentication.
+
+
+### Step 1: Get Your Elasticsearch Endpoint
+
+You'll need the endpoint URL for your Elasticsearch cluster. This typically looks like:
+
+- **Cloud:** `https://your-cluster.es.region.aws.com:443`
+- **Self-hosted:** `https://elasticsearch.example.com:9200`
+
+### Step 2: Configure Authentication
+
+Elasticsearch requires authentication. You can use either:
+
+**API Key:** A base64-encoded API key. You can create one in Elasticsearch by running:
+
+```bash
+POST /_security/api_key
+```
+
+**Username & Password:** Basic authentication using your Elasticsearch username and password.
+
+### Step 3: Select Indices
+
+Specify which indices to search. You can:
+
+- Use wildcards: `logs-*` to match multiple indices
+- List specific indices: `logs-2024, documents-2024`
+- Leave empty to search all accessible indices (not recommended for performance)
+
+
+Enable periodic sync to automatically re-index documents when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
+
+
+---
+
+## Connecting to SurfSense
+
+1. Navigate to the Connector Dashboard and select the **Elasticsearch** Connector.
+2. Fill in the required fields:
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| **Connector Name** | A friendly name to identify this connector | `My Elasticsearch Connector` |
+| **Elasticsearch Endpoint URL** | The full URL of your Elasticsearch cluster | `https://your-cluster.es.region.aws.com:443` |
+| **API Key** | Your base64-encoded API key (if using API key auth) | |
+| **Username** | Your Elasticsearch username (if using basic auth) | |
+| **Password** | Your Elasticsearch password (if using basic auth) | |
+| **Indices** | Comma-separated list of indices to search | `logs-*, documents-2024` |
+
+3. Click **Connect** to establish the connection.
+4. Once connected, your Elasticsearch documents will be indexed automatically.
+
+---
+
+## Advanced Configuration
+
+### Search Query
+
+The default query used for searches. Use `*` to match all documents, or specify a more complex Elasticsearch query.
+
+### Search Fields
+
+Limit searches to specific fields for better performance. Common fields include:
+
+- `title` - Document titles
+- `content` - Main content
+- `description` - Descriptions
+
+Leave empty to search all fields in your documents.
+
+### Maximum Documents
+
+Set a limit on the number of documents retrieved per search (1-10,000). This helps control response times and resource usage. Leave empty to use Elasticsearch's default limit.
+
+---
+
+## Troubleshooting
+
+### Connection Issues
+
+- **Invalid URL:** Ensure your endpoint URL includes the protocol (https://) and port number if required.
+- **SSL/TLS Errors:** Verify that your cluster uses HTTPS and the certificate is valid. Self-signed certificates may require additional configuration.
+- **Connection Timeout:** Check your network connectivity and firewall settings. Ensure the Elasticsearch cluster is accessible from SurfSense servers.
+
+### Authentication Issues
+
+- **Invalid Credentials:** Double-check your username/password or API key. API keys must be base64-encoded.
+- **Permission Denied:** Ensure your API key or user account has read permissions for the indices you want to search.
+- **API Key Format:** Elasticsearch API keys are typically base64-encoded strings. Make sure you're using the full key value.
+
+### Search Issues
+
+- **No Results:** Verify that your index selection matches existing indices. Use wildcards carefully.
+- **Slow Searches:** Limit the number of indices or use specific index names instead of wildcards. Reduce the maximum documents limit.
+- **Field Not Found:** Ensure the search fields you specify actually exist in your Elasticsearch documents.
diff --git a/surfsense_web/content/docs/connectors/github.mdx b/surfsense_web/content/docs/connectors/github.mdx
index 6a4574ec4..ce60d2a32 100644
--- a/surfsense_web/content/docs/connectors/github.mdx
+++ b/surfsense_web/content/docs/connectors/github.mdx
@@ -3,9 +3,18 @@ title: GitHub
description: Connect your GitHub repositories to SurfSense
---
-# GitHub Connector
+# GitHub Integration Setup Guide
-Connect your GitHub repositories to SurfSense for code search and AI-powered insights. The connector uses [gitingest](https://gitingest.com) to efficiently index entire codebases.
+This guide walks you through connecting your GitHub repositories to SurfSense for code search and AI-powered insights.
+
+## How it works
+
+The GitHub connector uses [gitingest](https://gitingest.com) to fetch and process repository contents from GitHub.
+
+- For follow-up indexing runs, the connector retrieves the latest repository state and updates changed files.
+- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
+
+---
## What Gets Indexed
@@ -15,7 +24,9 @@ Connect your GitHub repositories to SurfSense for code search and AI-powered ins
| Documentation | README files, Markdown documents, text files |
| Configuration | JSON, YAML, TOML, .env examples, Dockerfiles |
-> ⚠️ Binary files and files larger than 5MB are automatically excluded.
+
+ Binary files and files larger than 5MB are automatically excluded.
+
---
@@ -39,7 +50,13 @@ For private repos, you need a GitHub Personal Access Token (PAT).
2. Set an expiration
3. Click **Generate token** and copy it
-> ⚠️ The token starts with `ghp_`. Store it securely.
+
+ The token starts with `ghp_`. Store it securely.
+
+
+
+Enable periodic sync to automatically re-index repositories when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
+
---
@@ -53,21 +70,6 @@ For private repos, you need a GitHub Personal Access Token (PAT).
---
-## Periodic Sync
-
-Enable periodic sync to automatically re-index repositories when content changes:
-
-| Frequency | Use Case |
-|-----------|----------|
-| Every 5 minutes | Active development |
-| Every 15 minutes | Frequent commits |
-| Every hour | Regular workflow |
-| Every 6 hours | Less active repos |
-| Daily | Reference repositories |
-| Weekly | Stable codebases |
-
----
-
## Troubleshooting
**Repository not found**
diff --git a/surfsense_web/content/docs/connectors/gmail.mdx b/surfsense_web/content/docs/connectors/gmail.mdx
index 434e6ae4d..2b514f89e 100644
--- a/surfsense_web/content/docs/connectors/gmail.mdx
+++ b/surfsense_web/content/docs/connectors/gmail.mdx
@@ -60,7 +60,9 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+

diff --git a/surfsense_web/content/docs/connectors/google-calendar.mdx b/surfsense_web/content/docs/connectors/google-calendar.mdx
index cc1eae545..7919d0361 100644
--- a/surfsense_web/content/docs/connectors/google-calendar.mdx
+++ b/surfsense_web/content/docs/connectors/google-calendar.mdx
@@ -59,7 +59,9 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+

diff --git a/surfsense_web/content/docs/connectors/google-drive.mdx b/surfsense_web/content/docs/connectors/google-drive.mdx
index 00ea2f610..402b25566 100644
--- a/surfsense_web/content/docs/connectors/google-drive.mdx
+++ b/surfsense_web/content/docs/connectors/google-drive.mdx
@@ -60,7 +60,9 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+

diff --git a/surfsense_web/content/docs/connectors/jira.mdx b/surfsense_web/content/docs/connectors/jira.mdx
index ebe639d6d..c6b5a26e1 100644
--- a/surfsense_web/content/docs/connectors/jira.mdx
+++ b/surfsense_web/content/docs/connectors/jira.mdx
@@ -28,7 +28,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
2. Check the box to agree to Atlassian's developer terms
3. Click **"Create"**
-> ℹ️ New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
+
+ New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
+

@@ -41,7 +43,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
```
3. Click **"Save changes"**
-> ℹ️ You can enter up to 10 redirect URIs, one per line.
+
+ You can enter up to 10 redirect URIs, one per line.
+

@@ -60,8 +64,8 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
| Scope Name | Code | Description |
|------------|------|-------------|
-| ✅ View Jira issue data | `read:jira-work` | Read Jira project and issue data, search for issues, and objects associated with issues like attachments and worklogs |
-| ✅ View user profiles | `read:jira-user` | View user information in Jira that the user has access to, including usernames, email addresses, and avatars |
+| View Jira issue data | `read:jira-work` | Read Jira project and issue data, search for issues, and objects associated with issues like attachments and worklogs |
+| View user profiles | `read:jira-user` | View user information in Jira that the user has access to, including usernames, email addresses, and avatars |
4. Click **"Save"**
@@ -72,7 +76,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
1. In the left sidebar, click **"Settings"**
2. Copy your **Client ID** and **Client Secret**
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+
---
diff --git a/surfsense_web/content/docs/connectors/linear.mdx b/surfsense_web/content/docs/connectors/linear.mdx
index f9dc9a62b..5fb7bc8c5 100644
--- a/surfsense_web/content/docs/connectors/linear.mdx
+++ b/surfsense_web/content/docs/connectors/linear.mdx
@@ -30,7 +30,7 @@ Fill in the application details:
### Settings
-- ✅ **Public** - Enable this to allow the application to be installed by other workspaces
+- **Public** - Enable this to allow the application to be installed by other workspaces
Click **Create** to create the application.
@@ -43,7 +43,9 @@ After creating the application, you'll see your OAuth credentials:
1. Copy your **Client ID**
2. Copy your **Client Secret**
-> ⚠️ Never share your client secret publicly.
+
+ Never share your client secret publicly.
+

diff --git a/surfsense_web/content/docs/connectors/luma.mdx b/surfsense_web/content/docs/connectors/luma.mdx
index e16e5a949..a218be46b 100644
--- a/surfsense_web/content/docs/connectors/luma.mdx
+++ b/surfsense_web/content/docs/connectors/luma.mdx
@@ -3,4 +3,68 @@ title: Luma
description: Connect your Luma events to SurfSense
---
-# Documentation in progress
\ No newline at end of file
+# Luma Integration Setup Guide
+
+This guide walks you through connecting your Luma events to SurfSense for event search and AI-powered insights.
+
+## How it works
+
+The Luma connector uses the Luma API to fetch all events that your API key has access to.
+
+- For follow-up indexing runs, the connector retrieves events that have been updated since the last indexing attempt.
+- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
+
+---
+
+## Authorization
+
+
+You need a Luma API key to use this connector. The key will be used to read your Luma events with read-only permissions.
+
+
+### Step 1: Get Your API Key
+
+1. Log into your Luma account
+2. Navigate to your account settings
+3. Go to API settings or Developer settings
+4. Generate a new API key
+5. Copy the generated API key
+
+You can also visit [Luma API Docs](https://docs.luma.com/reference/getting-started-with-your-api) for more information.
+
+### Step 2: Grant Necessary Access
+
+The API key will have access to all events that your user account can see. Make sure your account has appropriate permissions for the events you want to index.
+
+
+Enable periodic sync to automatically re-index events when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
+
+
+---
+
+## Connecting to SurfSense
+
+1. Navigate to the Connector Dashboard and select the **Luma** Connector.
+2. Fill in the required fields:
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| **Connector Name** | A friendly name to identify this connector | `My Luma Connector` |
+| **Luma API Key** | Your Luma API key (will be encrypted and stored securely) | |
+
+3. Click **Connect** to establish the connection.
+4. Once connected, your Luma events will be indexed automatically.
+
+### What Gets Indexed
+
+The Luma connector indexes the following data:
+
+| Data Type | Description |
+|-----------|-------------|
+| Event Details | Titles, descriptions, metadata |
+| Attendee Info | Attendee information and lists |
+| Event Metadata | Dates, locations, settings |
+
+
+ Event attachments and linked files are not indexed by this connector.
+
diff --git a/surfsense_web/content/docs/connectors/meta.json b/surfsense_web/content/docs/connectors/meta.json
index b41e92ab9..c6f7ab383 100644
--- a/surfsense_web/content/docs/connectors/meta.json
+++ b/surfsense_web/content/docs/connectors/meta.json
@@ -17,7 +17,8 @@
"luma",
"circleback",
"elasticsearch",
- "bookstack"
+ "bookstack",
+ "obsidian"
],
"defaultOpen": false
}
diff --git a/surfsense_web/content/docs/connectors/microsoft-teams.mdx b/surfsense_web/content/docs/connectors/microsoft-teams.mdx
index daa6eb375..53f36c249 100644
--- a/surfsense_web/content/docs/connectors/microsoft-teams.mdx
+++ b/surfsense_web/content/docs/connectors/microsoft-teams.mdx
@@ -58,7 +58,9 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll

-> ⚠️ Never share your client secret publicly or include it in code repositories.
+
+ Never share your client secret publicly or include it in code repositories.
+
## Step 6: Configure API Permissions
@@ -78,7 +80,9 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
6. Click **"Add permissions"**
-> ⚠️ The `ChannelMessage.Read.All` permission requires admin consent. An admin will need to click **"Grant admin consent for [Directory]"** for full functionality.
+
+ The `ChannelMessage.Read.All` permission requires admin consent. An admin will need to click **"Grant admin consent for [Directory]"** for full functionality.
+

diff --git a/surfsense_web/content/docs/connectors/notion.mdx b/surfsense_web/content/docs/connectors/notion.mdx
index 936972f7e..0612c4f4f 100644
--- a/surfsense_web/content/docs/connectors/notion.mdx
+++ b/surfsense_web/content/docs/connectors/notion.mdx
@@ -52,14 +52,13 @@ After creating the integration, you'll see the configuration page with your cred
### Set Required Capabilities
-Under **Content Capabilities**, enable:
-- ✅ Read content
+Enable the following capabilities:
-Under **Comment Capabilities**, enable:
-- ✅ Read comments
-
-Under **User Capabilities**, select:
-- 🔘 Read user information including email addresses
+| Capability Type | Required Setting |
+|----------------|------------------|
+| **Content Capabilities** | Read content |
+| **Comment Capabilities** | Read comments |
+| **User Capabilities** | Read user information including email addresses |
Click **Save** to apply the capabilities.
diff --git a/surfsense_web/content/docs/connectors/obsidian.mdx b/surfsense_web/content/docs/connectors/obsidian.mdx
new file mode 100644
index 000000000..c8475c97f
--- /dev/null
+++ b/surfsense_web/content/docs/connectors/obsidian.mdx
@@ -0,0 +1,143 @@
+---
+title: Obsidian
+description: Connect your Obsidian vault to SurfSense
+---
+
+# Obsidian Integration Setup Guide
+
+This guide walks you through connecting your Obsidian vault to SurfSense for note search and AI-powered insights.
+
+
+ This connector requires direct file system access and only works with self-hosted SurfSense installations.
+
+
+## How it works
+
+The Obsidian connector scans your local Obsidian vault directory and indexes all Markdown files. It preserves your note structure and extracts metadata from YAML frontmatter.
+
+- For follow-up indexing runs, the connector uses content hashing to skip unchanged files for faster sync.
+- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
+
+---
+
+## What Gets Indexed
+
+| Content Type | Description |
+|--------------|-------------|
+| Markdown Files | All `.md` files in your vault |
+| Frontmatter | YAML metadata (title, tags, aliases, dates) |
+| Wiki Links | Links between notes (`[[note]]`) |
+| Inline Tags | Tags throughout your notes (`#tag`) |
+| Note Content | Full content with intelligent chunking |
+
+
+ Binary files and attachments are not indexed by default. Enable "Include Attachments" to index embedded files.
+
+
+---
+
+## Quick Start (Local Installation)
+
+1. Navigate to **Connectors** → **Add Connector** → **Obsidian**
+2. Enter your vault path: `/Users/yourname/Documents/MyVault`
+3. Enter a vault name (e.g., `Personal Notes`)
+4. Click **Connect Obsidian**
+
+
+ Find your vault path: In Obsidian, right-click any note → "Reveal in Finder" (macOS) or "Show in Explorer" (Windows).
+
+
+
+Enable periodic sync to automatically re-index notes when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
+
+
+---
+
+## Docker Setup
+
+For Docker deployments, you need to mount your Obsidian vault as a volume.
+
+### Step 1: Update docker-compose.yml
+
+Add your vault as a volume mount to the SurfSense backend service:
+
+```yaml
+services:
+ surfsense:
+ # ... other config
+ volumes:
+ - /path/to/your/obsidian/vault:/app/obsidian_vaults/my-vault:ro
+```
+
+
+ The `:ro` flag mounts the vault as read-only, which is recommended for security.
+
+
+### Step 2: Configure the Connector
+
+Use the **container path** (not your local path) when setting up the connector:
+
+| Your Local Path | Container Path (use this) |
+|-----------------|---------------------------|
+| `/Users/john/Documents/MyVault` | `/app/obsidian_vaults/my-vault` |
+| `C:\Users\john\Documents\MyVault` | `/app/obsidian_vaults/my-vault` |
+
+### Example: Multiple Vaults
+
+```yaml
+volumes:
+ - /Users/john/Documents/PersonalNotes:/app/obsidian_vaults/personal:ro
+ - /Users/john/Documents/WorkNotes:/app/obsidian_vaults/work:ro
+```
+
+Then create separate connectors for each vault using `/app/obsidian_vaults/personal` and `/app/obsidian_vaults/work`.
+
+---
+
+## Connector Configuration
+
+| Field | Description | Required |
+|-------|-------------|----------|
+| **Connector Name** | A friendly name to identify this connector | Yes |
+| **Vault Path** | Absolute path to your vault (container path for Docker) | Yes |
+| **Vault Name** | Display name for your vault in search results | Yes |
+| **Exclude Folders** | Comma-separated folder names to skip | No |
+| **Include Attachments** | Index embedded files (images, PDFs) | No |
+
+---
+
+## Recommended Exclusions
+
+Common folders to exclude from indexing:
+
+| Folder | Reason |
+|--------|--------|
+| `.obsidian` | Obsidian config files (always exclude) |
+| `.trash` | Obsidian's trash folder |
+| `templates` | Template files you don't want searchable |
+| `daily-notes` | If you want to exclude daily notes |
+| `attachments` | If not using "Include Attachments" |
+
+Default exclusions: `.obsidian,.trash`
+
+---
+
+## Troubleshooting
+
+**Vault not found / Permission denied**
+- Verify the path exists and is accessible
+- For Docker: ensure the volume is mounted correctly in `docker-compose.yml`
+- Check file permissions: SurfSense needs read access to the vault directory
+
+**No notes indexed**
+- Ensure your vault contains `.md` files
+- Check that notes aren't in excluded folders
+- Verify the path points to the vault root (contains `.obsidian` folder)
+
+**Changes not appearing**
+- Wait for the next sync cycle, or manually trigger re-indexing
+- For Docker: restart the container if you modified volume mounts
+
+**Docker: "path not found" error**
+- Use the container path (`/app/obsidian_vaults/...`), not your local path
+- Verify the volume mount in `docker-compose.yml` matches
diff --git a/surfsense_web/content/docs/connectors/slack.mdx b/surfsense_web/content/docs/connectors/slack.mdx
index ccabe6f9e..072b83343 100644
--- a/surfsense_web/content/docs/connectors/slack.mdx
+++ b/surfsense_web/content/docs/connectors/slack.mdx
@@ -21,7 +21,9 @@ This guide walks you through setting up a Slack OAuth integration for SurfSense.
2. Select the workspace to develop your app in
3. Click **"Create App"**
-> ⚠️ You won't be able to change the workspace later. The workspace will control the app even if you leave it.
+
+ You won't be able to change the workspace later. The workspace will control the app even if you leave it.
+

@@ -32,7 +34,9 @@ After creating the app, you'll be taken to the **Basic Information** page. Here
1. Copy your **Client ID**
2. Copy your **Client Secret** (click Show to reveal)
-> ⚠️ Never share your app credentials publicly.
+
+ Never share your app credentials publicly.
+

diff --git a/surfsense_web/hooks/use-global-loading.ts b/surfsense_web/hooks/use-global-loading.ts
new file mode 100644
index 000000000..baaa1f089
--- /dev/null
+++ b/surfsense_web/hooks/use-global-loading.ts
@@ -0,0 +1,104 @@
+"use client";
+
+import { useAtom } from "jotai";
+import { useCallback, useEffect, useRef } from "react";
+import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
+
+// Global counter to generate unique IDs for each loading request
+let loadingIdCounter = 0;
+
+// Track the current active loading ID globally
+let currentLoadingId: number | null = null;
+
+// Pending hide timeout - allows new loading requests to take over before hiding
+let pendingHideTimeout: ReturnType | null = null;
+
+/**
+ * Hook to control the global loading screen.
+ * The spinner is always mounted in the DOM to prevent animation reset.
+ */
+export function useGlobalLoading() {
+ const [loading, setLoading] = useAtom(globalLoadingAtom);
+
+ const show = useCallback(
+ (message?: string, variant: "login" | "default" = "default") => {
+ // Cancel any pending hide - new loading request takes over
+ if (pendingHideTimeout) {
+ clearTimeout(pendingHideTimeout);
+ pendingHideTimeout = null;
+ }
+
+ const id = ++loadingIdCounter;
+ currentLoadingId = id;
+ setLoading({ isLoading: true, message, variant });
+ return id;
+ },
+ [setLoading]
+ );
+
+ const hide = useCallback(
+ (id?: number) => {
+ // Only hide if this is the current loading, or if no ID provided (force hide)
+ if (id === undefined || id === currentLoadingId) {
+ // Use a small delay to allow React to flush pending mounts
+ // This prevents flash when transitioning between loading states
+ if (pendingHideTimeout) {
+ clearTimeout(pendingHideTimeout);
+ }
+
+ pendingHideTimeout = setTimeout(() => {
+ // Double-check we're still the current loading after the delay
+ if (id === undefined || id === currentLoadingId) {
+ currentLoadingId = null;
+ setLoading({ isLoading: false, message: undefined, variant: "default" });
+ }
+ pendingHideTimeout = null;
+ }, 50); // Small delay to allow next component to mount and show loading
+ }
+ },
+ [setLoading]
+ );
+
+ return { show, hide, isLoading: loading.isLoading };
+}
+
+/**
+ * Hook that automatically shows/hides the global loading screen based on a condition.
+ * Useful for components that show loading on mount and hide on unmount.
+ *
+ * Uses ownership tracking to prevent flashes when multiple components
+ * transition loading states (e.g., layout → page).
+ *
+ * @param shouldShow - Whether the loading screen should be visible
+ * @param message - Optional message to display
+ * @param variant - Visual style variant ("login" or "default")
+ */
+export function useGlobalLoadingEffect(
+ shouldShow: boolean,
+ message?: string,
+ variant: "login" | "default" = "default"
+) {
+ const { show, hide } = useGlobalLoading();
+ const loadingIdRef = useRef(null);
+
+ useEffect(() => {
+ if (shouldShow) {
+ // Show loading and store the ID
+ loadingIdRef.current = show(message, variant);
+ } else if (loadingIdRef.current !== null) {
+ // Only hide if we were the ones showing loading
+ hide(loadingIdRef.current);
+ loadingIdRef.current = null;
+ }
+ }, [shouldShow, message, variant, show, hide]);
+
+ // Cleanup on unmount - only hide if we're still the active loading
+ useEffect(() => {
+ return () => {
+ if (loadingIdRef.current !== null) {
+ hide(loadingIdRef.current);
+ loadingIdRef.current = null;
+ }
+ };
+ }, [hide]);
+}
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index 8ca382669..f14c73ddc 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -2,7 +2,8 @@
"common": {
"app_name": "SurfSense",
"welcome": "Welcome",
- "loading": "Loading...",
+ "loading": "Loading",
+ "initializing": "Initializing",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
@@ -45,6 +46,7 @@
"hide_password": "Hide password",
"remember_me": "Remember Me",
"sign_in": "Sign In",
+ "signing_in": "Signing in",
"sign_up": "Sign Up",
"sign_in_with": "Sign in with {provider}",
"dont_have_account": "Don't have an account?",
@@ -76,9 +78,10 @@
"passwords_no_match": "Passwords do not match",
"password_mismatch": "Password Mismatch",
"passwords_no_match_desc": "The passwords you entered do not match",
- "creating_account": "Creating your account...",
- "creating_account_btn": "Creating account...",
- "redirecting_login": "Redirecting to login page..."
+ "creating_account": "Creating your account",
+ "creating_account_btn": "Creating account",
+ "redirecting_login": "Redirecting to login page",
+ "processing_authentication": "Processing authentication"
},
"searchSpace": {
"create_title": "Create Search Space",
@@ -143,12 +146,15 @@
"api_keys": "API Keys",
"profile": "Profile",
"loading_dashboard": "Loading Dashboard",
- "checking_auth": "Checking authentication...",
+ "checking_auth": "Checking authentication",
"loading_config": "Loading Configuration",
- "checking_llm_prefs": "Checking your LLM preferences...",
+ "checking_llm_prefs": "Checking your LLM preferences",
+ "setting_up_ai": "Setting up AI",
"config_error": "Configuration Error",
"failed_load_llm_config": "Failed to load your LLM configuration",
"error_loading_chats": "Error loading chats",
+ "loading_chat": "Loading chat",
+ "loading_document": "Loading document",
"no_recent_chats": "No recent chats",
"error_loading_space": "Error loading search space",
"unknown_search_space": "Unknown Search Space",
@@ -165,7 +171,7 @@
"create_search_space": "Create Search Space",
"add_new_search_space": "Add New Search Space",
"loading": "Loading",
- "fetching_spaces": "Fetching your search spaces...",
+ "fetching_spaces": "Fetching your search spaces",
"may_take_moment": "This may take a moment",
"error": "Error",
"something_wrong": "Something went wrong",
@@ -316,10 +322,10 @@
"columns": "Columns",
"confirm_delete": "Confirm Delete",
"confirm_delete_desc": "Are you sure you want to delete {count} document(s)? This action cannot be undone.",
- "uploading": "Uploading...",
+ "uploading": "Uploading",
"upload_success": "Document uploaded successfully",
"upload_failed": "Failed to upload document",
- "loading": "Loading documents...",
+ "loading": "Loading documents",
"error_loading": "Error loading documents",
"retry": "Retry",
"no_documents": "No documents found",
@@ -386,8 +392,8 @@
"selected_files": "Selected Files ({count})",
"total_size": "Total size",
"clear_all": "Clear all",
- "uploading_files": "Uploading files...",
- "uploading": "Uploading...",
+ "uploading_files": "Uploading files",
+ "uploading": "Uploading",
"upload_button": "Upload {count} {count, plural, one {file} other {files}}",
"upload_initiated": "Upload Task Initiated",
"upload_initiated_desc": "Files Uploading Initiated",
@@ -723,12 +729,24 @@
"searchSpaceSettings": {
"title": "Search Space Settings",
"back_to_app": "Back to app",
+ "nav_general": "General",
+ "nav_general_desc": "Name, description & basic info",
"nav_agent_configs": "Agent Configs",
"nav_agent_configs_desc": "LLM models with prompts & citations",
"nav_role_assignments": "Role Assignments",
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_system_instructions": "System Instructions",
- "nav_system_instructions_desc": "SearchSpace-wide AI instructions"
+ "nav_system_instructions_desc": "SearchSpace-wide AI instructions",
+ "general_name_label": "Name",
+ "general_name_placeholder": "Enter search space name",
+ "general_name_description": "A unique name for your search space.",
+ "general_description_label": "Description",
+ "general_description_placeholder": "Enter search space description",
+ "general_description_description": "A brief description of what this search space will be used for.",
+ "general_reset": "Reset Changes",
+ "general_save": "Save Changes",
+ "general_saving": "Saving",
+ "general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them."
},
"homepage": {
"hero_title_part1": "The AI Workspace",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 3769d9dfc..6838b0f52 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -3,6 +3,7 @@
"app_name": "SurfSense",
"welcome": "欢迎",
"loading": "加载中...",
+ "initializing": "正在初始化",
"save": "保存",
"cancel": "取消",
"delete": "删除",
@@ -45,6 +46,7 @@
"hide_password": "隐藏密码",
"remember_me": "记住我",
"sign_in": "登录",
+ "signing_in": "正在登录",
"sign_up": "注册",
"sign_in_with": "使用 {provider} 登录",
"dont_have_account": "还没有账户?",
@@ -76,9 +78,10 @@
"passwords_no_match": "密码不匹配",
"password_mismatch": "密码不匹配",
"passwords_no_match_desc": "您输入的密码不一致",
- "creating_account": "正在创建您的账户...",
- "creating_account_btn": "创建中...",
- "redirecting_login": "正在跳转到登录页面..."
+ "creating_account": "正在创建您的账户",
+ "creating_account_btn": "创建中",
+ "redirecting_login": "正在跳转到登录页面",
+ "processing_authentication": "正在处理身份验证"
},
"searchSpace": {
"create_title": "创建搜索空间",
@@ -128,12 +131,15 @@
"api_keys": "API 密钥",
"profile": "个人资料",
"loading_dashboard": "正在加载仪表盘",
- "checking_auth": "正在检查身份验证...",
+ "checking_auth": "正在检查身份验证",
"loading_config": "正在加载配置",
- "checking_llm_prefs": "正在检查您的 LLM 偏好设置...",
+ "checking_llm_prefs": "正在检查您的 LLM 偏好设置",
+ "setting_up_ai": "正在设置 AI",
"config_error": "配置错误",
"failed_load_llm_config": "无法加载您的 LLM 配置",
"error_loading_chats": "加载对话失败",
+ "loading_chat": "正在加载对话",
+ "loading_document": "正在加载文档",
"no_recent_chats": "暂无最近对话",
"error_loading_space": "加载搜索空间失败",
"unknown_search_space": "未知搜索空间",
@@ -150,7 +156,7 @@
"create_search_space": "创建搜索空间",
"add_new_search_space": "添加新的搜索空间",
"loading": "加载中",
- "fetching_spaces": "正在获取您的搜索空间...",
+ "fetching_spaces": "正在获取您的搜索空间",
"may_take_moment": "这可能需要一些时间",
"error": "错误",
"something_wrong": "出现错误",
@@ -708,12 +714,24 @@
"searchSpaceSettings": {
"title": "搜索空间设置",
"back_to_app": "返回应用",
+ "nav_general": "常规",
+ "nav_general_desc": "名称、描述和基本信息",
"nav_agent_configs": "代理配置",
"nav_agent_configs_desc": "LLM 模型配置提示词和引用",
"nav_role_assignments": "角色分配",
"nav_role_assignments_desc": "为代理角色分配配置",
"nav_system_instructions": "系统指令",
- "nav_system_instructions_desc": "搜索空间级别的 AI 指令"
+ "nav_system_instructions_desc": "搜索空间级别的 AI 指令",
+ "general_name_label": "名称",
+ "general_name_placeholder": "输入搜索空间名称",
+ "general_name_description": "您的搜索空间的唯一名称。",
+ "general_description_label": "描述",
+ "general_description_placeholder": "输入搜索空间描述",
+ "general_description_description": "此搜索空间用途的简要描述。",
+ "general_reset": "重置更改",
+ "general_save": "保存更改",
+ "general_saving": "保存中...",
+ "general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。"
},
"homepage": {
"hero_title_part1": "AI 工作空间",