Error Icon
@@ -118,13 +118,13 @@ export function LocalLoginForm() {
{error.title}
-
{error.message}
+
{error.message}
{
setError({ title: null, message: null });
}}
- className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
+ className="flex-shrink-0 text-destructive hover:text-destructive/90 transition-colors"
aria-label="Dismiss error"
type="button"
>
@@ -150,10 +150,7 @@ export function LocalLoginForm() {
-
+
{t("email")}
setUsername(e.target.value)}
- className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
+ className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
error.title
- ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
- : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
+ ? "border-destructive focus:border-destructive focus:ring-destructive"
+ : "border-border focus:border-primary focus:ring-primary"
}`}
disabled={isLoggingIn}
/>
-
+
{t("password")}
@@ -187,17 +181,17 @@ export function LocalLoginForm() {
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
- className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
+ className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
error.title
- ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
- : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
+ ? "border-destructive focus:border-destructive focus:ring-destructive"
+ : "border-border focus:border-primary focus:ring-primary"
}`}
disabled={isLoggingIn}
/>
setShowPassword((prev) => !prev)}
- className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
+ className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? t("hide_password") : t("show_password")}
>
{showPassword ? : }
@@ -208,12 +202,12 @@ export function LocalLoginForm() {
{t("sign_in")}
{isLoggingIn && (
-
+
)}
@@ -221,12 +215,9 @@ export function LocalLoginForm() {
{authType === "LOCAL" && (
-
+
{t("dont_have_account")}{" "}
-
+
{t("sign_up")}
diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx
index 2c1a70ac9..b4b1fa2c7 100644
--- a/surfsense_web/app/(home)/page.tsx
+++ b/surfsense_web/app/(home)/page.tsx
@@ -1,7 +1,10 @@
"use client";
import dynamic from "next/dynamic";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
import { HeroSection } from "@/components/homepage/hero-section";
+import { getBearerToken } from "@/lib/auth-utils";
const FeaturesCards = dynamic(
() => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })),
@@ -26,6 +29,14 @@ const CTAHomepage = dynamic(
);
export default function HomePage() {
+ const router = useRouter();
+
+ useEffect(() => {
+ if (getBearerToken()) {
+ router.replace("/dashboard");
+ }
+ }, [router]);
+
return (
diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx
index 96fab2c6a..b9200c68f 100644
--- a/surfsense_web/app/(home)/register/page.tsx
+++ b/surfsense_web/app/(home)/register/page.tsx
@@ -11,6 +11,7 @@ import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
+import { getBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error";
import {
@@ -38,6 +39,10 @@ export default function RegisterPage() {
// Check authentication type and redirect if not LOCAL
useEffect(() => {
+ if (getBearerToken()) {
+ router.replace("/dashboard");
+ return;
+ }
if (AUTH_TYPE !== "LOCAL") {
router.push("/login");
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx
new file mode 100644
index 000000000..6cab15d08
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { motion } from "motion/react";
+import { BuyPagesContent } from "@/components/settings/buy-pages-content";
+
+export default function BuyPagesPage() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
index 25e4e990b..1715e525f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
@@ -183,6 +183,10 @@ export function DashboardClientLayout({
);
}
+ if (isOnboardingPage) {
+ return <>{children}>;
+ }
+
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx
index 25eeb4cab..43108c745 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx
@@ -16,6 +16,8 @@ export function getDocumentTypeLabel(type: string): string {
FILE: "File",
SLACK_CONNECTOR: "Slack",
TEAMS_CONNECTOR: "Microsoft Teams",
+ ONEDRIVE_FILE: "OneDrive",
+ DROPBOX_FILE: "Dropbox",
NOTION_CONNECTOR: "Notion",
YOUTUBE_VIDEO: "YouTube Video",
GITHUB_CONNECTOR: "GitHub",
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
index b32ad0ddf..92ced6e47 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
@@ -38,7 +38,6 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
@@ -234,6 +233,7 @@ export function DocumentsTableShell({
mentionedDocIds,
onToggleChatMention,
isSearchMode = false,
+ onOpenInTab,
}: {
documents: Document[];
loading: boolean;
@@ -253,6 +253,8 @@ export function DocumentsTableShell({
onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
/** Whether results are filtered by a search query or type filters */
isSearchMode?: boolean;
+ /** When provided, desktop "Preview" opens a document tab instead of the popup dialog */
+ onOpenInTab?: (doc: Document) => void;
}) {
const t = useTranslations("documents");
const { openDialog } = useDocumentUploadDialog();
@@ -742,9 +744,13 @@ export function DocumentsTableShell({
- handleViewDocument(doc)}>
+
+ onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)
+ }
+ >
- Preview
+ Open
{isEditable && (
)}
- {/* Document Content Viewer */}
- !open && handleCloseViewer()}>
-
-
-
+ {/* Document Content Viewer (mobile drawer) */}
+ !open && handleCloseViewer()}>
+
+
+
+
{viewingDoc?.title}
-
-
+
+
)}
-
-
+
+
{/* Document Metadata Viewer (Ctrl+Click) */}
- {isDeleting ? : "Delete"}
+ Delete
+ {isDeleting && }
@@ -1027,7 +1026,7 @@ export function DocumentsTableShell({
}}
>
- Preview
+ Open
{mobileActionDoc &&
EDITABLE_DOCUMENT_TYPES.includes(
@@ -1110,9 +1109,10 @@ export function DocumentsTableShell({
handleBulkDelete();
}}
disabled={isBulkDeleting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
- {isBulkDeleting ?
: "Delete"}
+
Delete
+ {isBulkDeleting &&
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
index a8b85e20b..5b7451c61 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
@@ -24,7 +24,7 @@ import {
} from "@/components/ui/dropdown-menu";
import type { Document } from "./types";
-const EDITABLE_DOCUMENT_TYPES = ["NOTE"] as const;
+const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
// SURFSENSE_DOCS are system-managed and cannot be deleted
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index ddbdc9dcc..a594b740d 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -33,14 +33,13 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
-import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
+import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread";
import { MobileEditorPanel } from "@/components/editor-panel/editor-panel";
import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
-import { Skeleton } from "@/components/ui/skeleton";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesSync } from "@/hooks/use-messages-sync";
import { documentsApiService } from "@/lib/apis/documents-api.service";
@@ -57,6 +56,7 @@ import {
buildContentForPersistence,
buildContentForUI,
type ContentPartsState,
+ FrameBatchedUpdater,
readSSEStream,
type ThinkingStepData,
updateThinkingSteps,
@@ -70,6 +70,7 @@ import {
getThreadMessages,
type ThreadRecord,
} from "@/lib/chat/thread-persistence";
+import { NotFoundError } from "@/lib/error";
import {
trackChatCreated,
trackChatError,
@@ -131,6 +132,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
* Tools that should render custom UI in the chat.
*/
const TOOLS_WITH_UI = new Set([
+ "web_search",
"generate_podcast",
"generate_report",
"generate_video_presentation",
@@ -144,6 +146,10 @@ const TOOLS_WITH_UI = new Set([
"delete_linear_issue",
"create_google_drive_file",
"delete_google_drive_file",
+ "create_onedrive_file",
+ "delete_onedrive_file",
+ "create_dropbox_file",
+ "delete_dropbox_file",
"create_calendar_event",
"update_calendar_event",
"delete_calendar_event",
@@ -192,6 +198,7 @@ export default function NewChatPage() {
const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
+ const removeChatTab = useSetAtom(removeChatTabAtom);
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
// Get current user for author info in shared chats
@@ -271,7 +278,6 @@ export default function NewChatPage() {
// Initialize thread and load messages
// For new chats (no urlChatId), we use lazy creation - thread is created on first message
- // biome-ignore lint/correctness/useExhaustiveDependencies: searchSpaceId triggers re-init when switching spaces with the same urlChatId
const initializeThread = useCallback(async () => {
setIsInitializing(true);
@@ -322,6 +328,14 @@ export default function NewChatPage() {
// This improves UX (instant load) and avoids orphan threads
} catch (error) {
console.error("[NewChatPage] Failed to initialize thread:", error);
+ if (urlChatId > 0 && error instanceof NotFoundError) {
+ removeChatTab(urlChatId);
+ if (typeof window !== "undefined") {
+ window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
+ }
+ toast.error("This chat was deleted.");
+ return;
+ }
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls
setThreadId(null);
@@ -332,15 +346,16 @@ export default function NewChatPage() {
}
}, [
urlChatId,
- searchSpaceId,
setMessageDocumentsMap,
setMentionedDocuments,
setSidebarDocuments,
closeReportPanel,
closeEditorPanel,
+ removeChatTab,
+ searchSpaceId,
]);
- // Initialize on mount
+ // Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
useEffect(() => {
initializeThread();
}, [initializeThread]);
@@ -483,18 +498,17 @@ export default function NewChatPage() {
// Add user message to state
const userMsgId = `msg-user-${Date.now()}`;
- // Include author metadata for shared chats
- const authorMetadata =
- currentThread?.visibility === "SEARCH_SPACE" && currentUser
- ? {
- custom: {
- author: {
- displayName: currentUser.display_name ?? null,
- avatarUrl: currentUser.avatar_url ?? null,
- },
+ // Always include author metadata so the UI layer can decide visibility
+ const authorMetadata = currentUser
+ ? {
+ custom: {
+ author: {
+ displayName: currentUser.display_name ?? null,
+ avatarUrl: currentUser.avatar_url ?? null,
},
- }
- : undefined;
+ },
+ }
+ : undefined;
const userMessage: ThreadMessageLike = {
id: userMsgId,
@@ -570,6 +584,7 @@ export default function NewChatPage() {
// Prepare assistant message
const assistantMsgId = `msg-assistant-${Date.now()}`;
const currentThinkingSteps = new Map
();
+ const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = {
contentParts: [],
@@ -641,33 +656,30 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
+ const flushMessages = () => {
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
+ : m
+ )
+ );
+ };
+ const scheduleFlush = () => batcher.schedule(flushMessages);
+
for await (const parsed of readSSEStream(response)) {
switch (parsed.type) {
case "text-delta":
appendText(contentPartsState, parsed.delta);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ scheduleFlush();
break;
case "tool-input-start":
- // Add tool call inline - this breaks the current text segment
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
case "tool-input-available": {
- // Update existing tool call's args, or add if not exists
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else {
@@ -679,23 +691,14 @@ export default function NewChatPage() {
parsed.input || {}
);
}
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
}
case "tool-output-available": {
- // Update the tool call with its result
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts);
- // Handle podcast-specific logic
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
- // Check if this is a podcast tool by looking at the content part
const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) {
const part = contentParts[idx];
@@ -704,13 +707,7 @@ export default function NewChatPage() {
}
}
}
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
}
@@ -718,14 +715,10 @@ export default function NewChatPage() {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
- updateThinkingSteps(contentPartsState, currentThinkingSteps);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
+ if (didUpdate) {
+ scheduleFlush();
+ }
}
break;
}
@@ -802,6 +795,8 @@ export default function NewChatPage() {
}
}
+ batcher.flush();
+
// Skip persistence for interrupted messages -- handleResume will persist the final version
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0 && !wasInterrupted) {
@@ -831,6 +826,7 @@ export default function NewChatPage() {
trackChatResponseReceived(searchSpaceId, currentThreadId);
}
} catch (error) {
+ batcher.dispose();
if (error instanceof Error && error.name === "AbortError") {
// Request was cancelled by user - persist partial response if any content was received
const hasContent = contentParts.some(
@@ -898,10 +894,11 @@ export default function NewChatPage() {
setMentionedDocuments,
setSidebarDocuments,
setMessageDocumentsMap,
+ setAgentCreatedDocuments,
queryClient,
- currentThread,
currentUser,
disabledTools,
+ updateChatTabTitle,
]
);
@@ -929,6 +926,7 @@ export default function NewChatPage() {
abortControllerRef.current = controller;
const currentThinkingSteps = new Map();
+ const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = {
contentParts: [],
@@ -1016,28 +1014,27 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
+ const flushMessages = () => {
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
+ : m
+ )
+ );
+ };
+ const scheduleFlush = () => batcher.schedule(flushMessages);
+
for await (const parsed of readSSEStream(response)) {
switch (parsed.type) {
case "text-delta":
appendText(contentPartsState, parsed.delta);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ scheduleFlush();
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
case "tool-input-available":
@@ -1054,13 +1051,7 @@ export default function NewChatPage() {
parsed.input || {}
);
}
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
case "tool-output-available":
@@ -1068,27 +1059,17 @@ export default function NewChatPage() {
result: parsed.output,
});
markInterruptsCompleted(contentParts);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
- updateThinkingSteps(contentPartsState, currentThinkingSteps);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
+ if (didUpdate) {
+ scheduleFlush();
+ }
}
break;
}
@@ -1142,6 +1123,8 @@ export default function NewChatPage() {
}
}
+ batcher.flush();
+
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0) {
try {
@@ -1158,6 +1141,7 @@ export default function NewChatPage() {
}
}
} catch (error) {
+ batcher.dispose();
if (error instanceof Error && error.name === "AbortError") {
return;
}
@@ -1303,6 +1287,7 @@ export default function NewChatPage() {
toolCallIndices: new Map(),
};
const { contentParts, toolCallIndices } = contentPartsState;
+ const batcher = new FrameBatchedUpdater();
// Add placeholder messages to UI
// Always add back the user message (with new query for edit, or original content for reload)
@@ -1347,28 +1332,27 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
+ const flushMessages = () => {
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
+ : m
+ )
+ );
+ };
+ const scheduleFlush = () => batcher.schedule(flushMessages);
+
for await (const parsed of readSSEStream(response)) {
switch (parsed.type) {
case "text-delta":
appendText(contentPartsState, parsed.delta);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ scheduleFlush();
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
case "tool-input-available":
@@ -1383,13 +1367,7 @@ export default function NewChatPage() {
parsed.input || {}
);
}
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
case "tool-output-available":
@@ -1404,27 +1382,17 @@ export default function NewChatPage() {
}
}
}
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
- updateThinkingSteps(contentPartsState, currentThinkingSteps);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
- : m
- )
- );
+ const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
+ if (didUpdate) {
+ scheduleFlush();
+ }
}
break;
}
@@ -1434,6 +1402,8 @@ export default function NewChatPage() {
}
}
+ batcher.flush();
+
// Persist messages after streaming completes
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0) {
@@ -1475,6 +1445,7 @@ export default function NewChatPage() {
if (error instanceof Error && error.name === "AbortError") {
return;
}
+ batcher.dispose();
console.error("[NewChatPage] Regeneration error:", error);
trackChatError(
searchSpaceId,
@@ -1482,7 +1453,6 @@ export default function NewChatPage() {
error instanceof Error ? error.message : "Unknown error"
);
toast.error("Failed to regenerate response. Please try again.");
- // Update assistant message with error
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
index b188d7c8f..4dba3bbb6 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useAtomValue, useSetAtom } from "jotai";
-import { motion } from "motion/react";
+import { useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@@ -13,19 +12,17 @@ import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
-import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
+import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
- const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
-
// Queries
const {
data: globalConfigs = [],
@@ -62,14 +59,12 @@ export default function OnboardPage() {
preferences.document_summary_llm_id !== null &&
preferences.document_summary_llm_id !== undefined;
- // If onboarding is already complete, redirect immediately
useEffect(() => {
if (!preferencesLoading && isOnboardingComplete) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]);
- // Auto-configure if global configs are available
useEffect(() => {
const autoConfigureWithGlobal = async () => {
if (hasAttemptedAutoConfig.current) return;
@@ -77,7 +72,6 @@ export default function OnboardPage() {
if (!globalConfigsLoaded) return;
if (isOnboardingComplete) return;
- // Only auto-configure if we have global configs
if (globalConfigs.length > 0) {
hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true);
@@ -97,7 +91,6 @@ export default function OnboardPage() {
description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`,
});
- // Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) {
console.error("Auto-configuration failed:", error);
@@ -119,13 +112,10 @@ export default function OnboardPage() {
router,
]);
- // Handle form submission
const handleSubmit = async (formData: LLMConfigFormData) => {
try {
- // Create the config
const newConfig = await createConfig(formData);
- // Auto-assign to all roles
await updatePreferences({
search_space_id: searchSpaceId,
data: {
@@ -138,7 +128,6 @@ export default function OnboardPage() {
description: "Redirecting to chat...",
});
- // Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) {
console.error("Failed to create config:", error);
@@ -150,124 +139,59 @@ export default function OnboardPage() {
const isSubmitting = isCreating || isUpdatingPreferences;
- // Loading state
- if (globalConfigsLoading || preferencesLoading || isAutoConfiguring) {
- return (
-
-
-
-
-
- {isAutoConfiguring ? "Setting up your AI..." : "Loading..."}
-
-
- {isAutoConfiguring
- ? "Auto-configuring with available settings"
- : "Please wait while we check your configuration"}
+ const isLoading = globalConfigsLoading || preferencesLoading || isAutoConfiguring;
+ useGlobalLoadingEffect(isLoading);
+
+ if (isLoading) {
+ return null;
+ }
+
+ if (globalConfigs.length > 0 && !isAutoConfiguring) {
+ return null;
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Configure Your AI
+
+ Add your LLM provider to get started with SurfSense
-
- {[0, 1, 2].map((i) => (
-
- ))}
-
-
-
- );
- }
+
- // If global configs exist but auto-config failed, show simple message
- if (globalConfigs.length > 0 && !isAutoConfiguring) {
- return null; // Will redirect via useEffect
- }
+ {/* Form card */}
+
+
+
- // No global configs - show the config form
- return (
-
-
-
- {/* Header */}
-
-
-
-
-
-
-
Configure Your AI
-
- Add your LLM provider to get started with SurfSense
-
-
-
-
- {/* Config Form */}
-
+
-
-
- LLM Configuration
-
-
-
-
-
-
-
- {/* Footer note */}
-
- You can add more configurations and customize settings anytime in{" "}
- setSearchSpaceSettingsDialog({ open: true, initialTab: "general" })}
- className="text-violet-500 hover:underline"
- >
- Settings
-
-
-
+
Start Using SurfSense
+ {isSubmitting &&
}
+
+
You can add more configurations later
+
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/purchase-cancel/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/purchase-cancel/page.tsx
new file mode 100644
index 000000000..0e5224700
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/purchase-cancel/page.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { CircleSlash2 } from "lucide-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+export default function PurchaseCancelPage() {
+ const params = useParams();
+ const searchSpaceId = String(params.search_space_id ?? "");
+
+ return (
+
+
+
+
+ Checkout canceled
+
+ No charge was made and your current pages are unchanged.
+
+
+
+ You can return to the pricing options and try again whenever you're ready.
+
+
+
+ Back to Buy Pages
+
+
+ Back to Dashboard
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx
new file mode 100644
index 000000000..82c6f8c74
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { CheckCircle2 } from "lucide-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { useEffect } from "react";
+import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+export default function PurchaseSuccessPage() {
+ const params = useParams();
+ const queryClient = useQueryClient();
+ const searchSpaceId = String(params.search_space_id ?? "");
+
+ useEffect(() => {
+ void queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
+ }, [queryClient]);
+
+ return (
+
+
+
+
+ Purchase complete
+
+ Your additional pages are being applied to your account now.
+
+
+
+
+ Your sidebar usage meter should refresh automatically in a moment.
+
+
+
+
+ Back to Dashboard
+
+
+ Buy More Pages
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
index d46594861..d9ca9efb3 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
@@ -308,7 +308,8 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
{invitesLoading ? (
) : (
- canInvite && activeInvites.length > 0 && (
+ canInvite &&
+ activeInvites.length > 0 && (
)
)}
@@ -763,7 +764,7 @@ function CreateInviteDialog({
-
+
Cancel
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx
new file mode 100644
index 000000000..4bcdcba7e
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react";
+import { useCallback, useState } from "react";
+import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
+import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+
+export function CommunityPromptsContent() {
+ const { data: prompts, isLoading, isError } = useAtomValue(publicPromptsAtom);
+ const { mutateAsync: copyPrompt } = useAtomValue(copyPromptMutationAtom);
+ const [copyingIds, setCopyingIds] = useState>(new Set());
+ const [expandedId, setExpandedId] = useState(null);
+
+ const handleCopy = useCallback(
+ async (id: number) => {
+ setCopyingIds((prev) => new Set(prev).add(id));
+ try {
+ await copyPrompt(id);
+ } catch {
+ // toast handled by mutation atom
+ } finally {
+ setCopyingIds((prev) => {
+ const next = new Set(prev);
+ next.delete(id);
+ return next;
+ });
+ }
+ },
+ [copyPrompt]
+ );
+
+ const list = prompts ?? [];
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+
Failed to load community prompts
+
Please try refreshing the page.
+
+ );
+ }
+
+ return (
+
+
+ Prompts shared by other users. Add any to your collection with one click.
+
+
+ {list.length === 0 && (
+
+
+
No community prompts yet
+
+ Share your own prompts from the My Prompts tab
+
+
+ )}
+
+ {list.length > 0 && (
+
+ {list.map((prompt) => (
+
+
+
+
+
+
+ {prompt.name}
+
+ {prompt.mode}
+
+ {prompt.author_name && (
+
+ by {prompt.author_name}
+
+ )}
+
+
+ {prompt.prompt}
+
+ {prompt.prompt.length > 100 && (
+
setExpandedId(expandedId === prompt.id ? null : prompt.id)}
+ className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
+ >
+ {expandedId === prompt.id ? "See less" : "See more"}
+
+ )}
+
+
handleCopy(prompt.id)}
+ >
+ {copyingIds.has(prompt.id) ? (
+
+ ) : (
+
+ )}
+ Add to mine
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx
new file mode 100644
index 000000000..522d71e59
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx
@@ -0,0 +1,347 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
+import { useCallback, useState } from "react";
+import { toast } from "sonner";
+import {
+ createPromptMutationAtom,
+ deletePromptMutationAtom,
+ updatePromptMutationAtom,
+} from "@/atoms/prompts/prompts-mutation.atoms";
+import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import type { PromptRead } from "@/contracts/types/prompts.types";
+
+interface PromptFormData {
+ name: string;
+ prompt: string;
+ mode: "transform" | "explore";
+ is_public: boolean;
+}
+
+const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
+
+export function PromptsContent() {
+ const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
+ const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
+ const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
+ const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
+
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [formData, setFormData] = useState(EMPTY_FORM);
+ const [isSaving, setIsSaving] = useState(false);
+ const [expandedId, setExpandedId] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [togglingPublicIds, setTogglingPublicIds] = useState>(new Set());
+
+ const handleSave = useCallback(async () => {
+ if (!formData.name.trim() || !formData.prompt.trim()) {
+ toast.error("Name and prompt are required");
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ if (editingId !== null) {
+ await updatePrompt({ id: editingId, ...formData });
+ } else {
+ await createPrompt(formData);
+ }
+ setShowForm(false);
+ setFormData(EMPTY_FORM);
+ setEditingId(null);
+ } catch {
+ // toast handled by mutation atoms
+ } finally {
+ setIsSaving(false);
+ }
+ }, [formData, editingId, createPrompt, updatePrompt]);
+
+ const handleEdit = useCallback((prompt: PromptRead) => {
+ setFormData({
+ name: prompt.name,
+ prompt: prompt.prompt,
+ mode: prompt.mode as "transform" | "explore",
+ is_public: prompt.is_public,
+ });
+ setEditingId(prompt.id);
+ setShowForm(true);
+ }, []);
+
+ const handleConfirmDelete = useCallback(async () => {
+ if (deleteTarget === null) return;
+ try {
+ await deletePrompt(deleteTarget);
+ } catch {
+ // toast handled by mutation atom
+ } finally {
+ setDeleteTarget(null);
+ }
+ }, [deleteTarget, deletePrompt]);
+
+ const handleTogglePublic = useCallback(
+ async (prompt: PromptRead) => {
+ if (togglingPublicIds.has(prompt.id)) return;
+ setTogglingPublicIds((prev) => new Set(prev).add(prompt.id));
+ try {
+ await updatePrompt({ id: prompt.id, is_public: !prompt.is_public });
+ } catch {
+ // toast handled by mutation atom
+ } finally {
+ setTogglingPublicIds((prev) => {
+ const next = new Set(prev);
+ next.delete(prompt.id);
+ return next;
+ });
+ }
+ },
+ [updatePrompt, togglingPublicIds]
+ );
+
+ const handleCancel = useCallback(() => {
+ setShowForm(false);
+ setFormData(EMPTY_FORM);
+ setEditingId(null);
+ }, []);
+
+ const list = prompts ?? [];
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+
Failed to load prompts
+
Please try refreshing the page.
+
+ );
+ }
+
+ return (
+
+
+
+ Create prompt templates triggered with{" "}
+ / in the
+ chat composer.
+
+ {!showForm && (
+
{
+ setShowForm(true);
+ setEditingId(null);
+ setFormData(EMPTY_FORM);
+ }}
+ className="shrink-0 gap-1.5"
+ >
+
+ New
+
+ )}
+
+
+ {showForm && (
+
+
+ {editingId !== null ? "Edit prompt" : "New prompt"}
+
+
+
+ Name
+ setFormData((p) => ({ ...p, name: e.target.value }))}
+ placeholder="e.g. Fix grammar"
+ />
+
+
+
+
+
+ Mode
+
+ setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
+ }
+ className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
+ >
+ Transform — rewrites or modifies your text
+ Explore — answers a question about your text
+
+
+
+
+ setFormData((p) => ({ ...p, is_public: checked }))}
+ />
+
+ Share with community
+
+
+
+
+
+ Cancel
+
+
+
+ {editingId !== null ? "Update" : "Create"}
+
+ {isSaving && }
+
+
+
+ )}
+
+ {list.length === 0 && !showForm && (
+
+
+
No prompts yet
+
+ Create prompts to quickly transform or explore text with /
+
+
+ )}
+
+ {list.length > 0 && (
+
+ {list.map((prompt) => (
+
+
+
+
+
+
+ {prompt.name}
+
+ {prompt.mode}
+
+ {prompt.is_public && (
+
+
+ Public
+
+ )}
+
+
+ {prompt.prompt}
+
+ {prompt.prompt.length > 100 && (
+
setExpandedId(expandedId === prompt.id ? null : prompt.id)}
+ className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
+ >
+ {expandedId === prompt.id ? "See less" : "See more"}
+
+ )}
+
+
+
handleTogglePublic(prompt)}
+ disabled={togglingPublicIds.has(prompt.id)}
+ className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none"
+ >
+ {togglingPublicIds.has(prompt.id) ? (
+
+ ) : prompt.is_public ? (
+
+ ) : (
+
+ )}
+
+
handleEdit(prompt)}
+ >
+
+
+
setDeleteTarget(prompt.id)}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
!open && setDeleteTarget(null)}
+ >
+
+
+ Delete prompt
+
+ This action cannot be undone. The prompt will be permanently removed.
+
+
+
+ Cancel
+ Delete
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx
new file mode 100644
index 000000000..2eb56520b
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Receipt } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types";
+import { stripeApiService } from "@/lib/apis/stripe-api.service";
+import { cn } from "@/lib/utils";
+
+const STATUS_STYLES: Record = {
+ completed: {
+ label: "Completed",
+ className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600",
+ },
+ pending: {
+ label: "Pending",
+ className: "bg-yellow-600 text-white border-transparent hover:bg-yellow-600",
+ },
+ failed: {
+ label: "Failed",
+ className: "bg-destructive text-white border-transparent hover:bg-destructive",
+ },
+};
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function formatAmount(purchase: PagePurchase): string {
+ if (purchase.amount_total == null) return "—";
+ const dollars = purchase.amount_total / 100;
+ const currency = (purchase.currency ?? "usd").toUpperCase();
+ return `$${dollars.toFixed(2)} ${currency}`;
+}
+
+export function PurchaseHistoryContent() {
+ const { data, isLoading } = useQuery({
+ queryKey: ["stripe-purchases"],
+ queryFn: () => stripeApiService.getPurchases(),
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const purchases = data?.purchases ?? [];
+
+ if (purchases.length === 0) {
+ return (
+
+
+
No purchases yet
+
+ Your page-pack purchases will appear here after checkout.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Date
+ Pages
+ Amount
+ Status
+
+
+
+ {purchases.map((p) => {
+ const style = STATUS_STYLES[p.status];
+ return (
+
+ {formatDate(p.created_at)}
+
+ {p.pages_granted.toLocaleString()}
+
+
+ {formatAmount(p)}
+
+
+ {style.label}
+
+
+ );
+ })}
+
+
+
+
+ Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}.
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx
index 4a32c2147..f727a2018 100644
--- a/surfsense_web/app/dashboard/layout.tsx
+++ b/surfsense_web/app/dashboard/layout.tsx
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useState } from "react";
+import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
+import { queryClient } from "@/lib/query-client/client";
interface DashboardLayoutProps {
children: React.ReactNode;
@@ -22,6 +24,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
redirectToLogin();
return;
}
+ queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
setIsCheckingAuth(false);
}, []);
diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx
index 2bd8f4462..525060bed 100644
--- a/surfsense_web/app/dashboard/page.tsx
+++ b/surfsense_web/app/dashboard/page.tsx
@@ -3,7 +3,7 @@
import { useAtomValue } from "jotai";
import { AlertCircle, Plus, Search } from "lucide-react";
import { motion } from "motion/react";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
@@ -89,6 +89,7 @@ function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
export default function DashboardPage() {
const router = useRouter();
+ const searchParams = useSearchParams();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const t = useTranslations("dashboard");
@@ -98,9 +99,11 @@ export default function DashboardPage() {
if (isLoading) return;
if (searchSpaces.length > 0) {
- router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
+ const params = searchParams.toString();
+ const query = params ? `?${params}` : "";
+ router.replace(`/dashboard/${searchSpaces[0].id}/new-chat${query}`);
}
- }, [isLoading, searchSpaces, router]);
+ }, [isLoading, searchSpaces, router, searchParams]);
// Show loading while fetching or while we have spaces and are about to redirect
const shouldShowLoading = isLoading || searchSpaces.length > 0;
diff --git a/surfsense_web/app/sitemap.ts b/surfsense_web/app/sitemap.ts
index f1f0bad72..9f3f7ebdf 100644
--- a/surfsense_web/app/sitemap.ts
+++ b/surfsense_web/app/sitemap.ts
@@ -133,6 +133,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily",
priority: 0.8,
},
+ {
+ url: "https://www.surfsense.com/docs/connectors/dropbox",
+ lastModified,
+ changeFrequency: "daily",
+ priority: 0.8,
+ },
{
url: "https://www.surfsense.com/docs/connectors/elasticsearch",
lastModified,
@@ -181,6 +187,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily",
priority: 0.8,
},
+ {
+ url: "https://www.surfsense.com/docs/connectors/microsoft-onedrive",
+ lastModified,
+ changeFrequency: "daily",
+ priority: 0.8,
+ },
{
url: "https://www.surfsense.com/docs/connectors/microsoft-teams",
lastModified,
diff --git a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
index dbaf441d0..362c3a690 100644
--- a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
+++ b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
@@ -2,6 +2,8 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateImageGenConfigRequest,
+ CreateImageGenConfigResponse,
+ DeleteImageGenConfigResponse,
GetImageGenConfigsResponse,
UpdateImageGenConfigRequest,
UpdateImageGenConfigResponse,
@@ -23,14 +25,14 @@ export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: CreateImageGenConfigRequest) => {
return imageGenConfigApiService.createConfig(request);
},
- onSuccess: () => {
- toast.success("Image model configuration created");
+ onSuccess: (_: CreateImageGenConfigResponse, request: CreateImageGenConfigRequest) => {
+ toast.success(`${request.name} created`);
queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to create image model configuration");
+ toast.error(error.message || "Failed to create image model");
},
};
});
@@ -48,7 +50,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
return imageGenConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => {
- toast.success("Image model configuration updated");
+ toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
});
@@ -57,7 +59,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to update image model configuration");
+ toast.error(error.message || "Failed to update image model");
},
};
});
@@ -71,21 +73,21 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["image-gen-configs", "delete"],
enabled: !!searchSpaceId,
- mutationFn: async (id: number) => {
- return imageGenConfigApiService.deleteConfig(id);
+ mutationFn: async (request: { id: number; name: string }) => {
+ return imageGenConfigApiService.deleteConfig(request.id);
},
- onSuccess: (_, id: number) => {
- toast.success("Image model configuration deleted");
+ onSuccess: (_: DeleteImageGenConfigResponse, request: { id: number; name: string }) => {
+ toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
(oldData: GetImageGenConfigsResponse | undefined) => {
if (!oldData) return oldData;
- return oldData.filter((config) => config.id !== id);
+ return oldData.filter((config) => config.id !== request.id);
}
);
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to delete image model configuration");
+ toast.error(error.message || "Failed to delete image model");
},
};
});
diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
index 8f81b7475..861606f80 100644
--- a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
+++ b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
@@ -2,7 +2,9 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateNewLLMConfigRequest,
+ CreateNewLLMConfigResponse,
DeleteNewLLMConfigRequest,
+ DeleteNewLLMConfigResponse,
GetNewLLMConfigsResponse,
UpdateLLMPreferencesRequest,
UpdateNewLLMConfigRequest,
@@ -25,14 +27,14 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: CreateNewLLMConfigRequest) => {
return newLLMConfigApiService.createConfig(request);
},
- onSuccess: () => {
- toast.success("Configuration created successfully");
+ onSuccess: (_: CreateNewLLMConfigResponse, request: CreateNewLLMConfigRequest) => {
+ toast.success(`${request.name} created`);
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to create configuration");
+ toast.error(error.message || "Failed to create LLM model");
},
};
});
@@ -50,7 +52,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
return newLLMConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => {
- toast.success("Configuration updated successfully");
+ toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
@@ -59,7 +61,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to update configuration");
+ toast.error(error.message || "Failed to update");
},
};
});
@@ -73,11 +75,14 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["new-llm-configs", "delete"],
enabled: !!searchSpaceId,
- mutationFn: async (request: DeleteNewLLMConfigRequest) => {
- return newLLMConfigApiService.deleteConfig(request);
+ mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
+ return newLLMConfigApiService.deleteConfig({ id: request.id });
},
- onSuccess: (_, request: DeleteNewLLMConfigRequest) => {
- toast.success("Configuration deleted successfully");
+ onSuccess: (
+ _: DeleteNewLLMConfigResponse,
+ request: DeleteNewLLMConfigRequest & { name: string }
+ ) => {
+ toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
(oldData: GetNewLLMConfigsResponse | undefined) => {
@@ -87,7 +92,7 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
);
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to delete configuration");
+ toast.error(error.message || "Failed to delete");
},
};
});
diff --git a/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts b/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts
new file mode 100644
index 000000000..6996185fe
--- /dev/null
+++ b/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts
@@ -0,0 +1,70 @@
+import { atomWithMutation } from "jotai-tanstack-query";
+import { toast } from "sonner";
+import type {
+ PromptCreateRequest,
+ PromptRead,
+ PromptUpdateRequest,
+} from "@/contracts/types/prompts.types";
+import { promptsApiService } from "@/lib/apis/prompts-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { queryClient } from "@/lib/query-client/client";
+
+export const createPromptMutationAtom = atomWithMutation(() => ({
+ mutationKey: ["prompts", "create"],
+ mutationFn: async (request: PromptCreateRequest) => {
+ return promptsApiService.create(request);
+ },
+ onSuccess: () => {
+ toast.success("Prompt created");
+ queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create prompt");
+ },
+}));
+
+export const updatePromptMutationAtom = atomWithMutation(() => ({
+ mutationKey: ["prompts", "update"],
+ mutationFn: async ({ id, ...data }: PromptUpdateRequest & { id: number }) => {
+ return promptsApiService.update(id, data);
+ },
+ onSuccess: () => {
+ toast.success("Prompt updated");
+ queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update prompt");
+ },
+}));
+
+export const deletePromptMutationAtom = atomWithMutation(() => ({
+ mutationKey: ["prompts", "delete"],
+ mutationFn: async (id: number) => {
+ return promptsApiService.delete(id);
+ },
+ onSuccess: (_: unknown, id: number) => {
+ toast.success("Prompt deleted");
+ queryClient.setQueryData(cacheKeys.prompts.all(), (old: PromptRead[] | undefined) => {
+ if (!old) return old;
+ return old.filter((p) => p.id !== id);
+ });
+ queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to delete prompt");
+ },
+}));
+
+export const copyPromptMutationAtom = atomWithMutation(() => ({
+ mutationKey: ["prompts", "copy"],
+ mutationFn: async (promptId: number) => {
+ return promptsApiService.copy(promptId);
+ },
+ onSuccess: () => {
+ toast.success("Prompt added to your collection");
+ queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to copy prompt");
+ },
+}));
diff --git a/surfsense_web/atoms/prompts/prompts-query.atoms.ts b/surfsense_web/atoms/prompts/prompts-query.atoms.ts
new file mode 100644
index 000000000..f9042c03a
--- /dev/null
+++ b/surfsense_web/atoms/prompts/prompts-query.atoms.ts
@@ -0,0 +1,23 @@
+import { atomWithQuery } from "jotai-tanstack-query";
+import { promptsApiService } from "@/lib/apis/prompts-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+export const promptsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.prompts.all(),
+ staleTime: 5 * 60 * 1000,
+ queryFn: async () => {
+ return promptsApiService.list();
+ },
+ };
+});
+
+export const publicPromptsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.prompts.public(),
+ staleTime: 2 * 60 * 1000,
+ queryFn: async () => {
+ return promptsApiService.listPublic();
+ },
+ };
+});
diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts
index 7ba115a95..22cc5373a 100644
--- a/surfsense_web/atoms/tabs/tabs.atom.ts
+++ b/surfsense_web/atoms/tabs/tabs.atom.ts
@@ -33,6 +33,9 @@ const initialState: TabsState = {
activeTabId: "chat-new",
};
+// Prevent race conditions where route-sync recreates a just-deleted chat tab.
+const deletedChatIdsAtom = atom>(new Set());
+
const sessionStorageAdapter = createJSONStorage(
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
);
@@ -71,6 +74,10 @@ export const syncChatTabAtom = atom(
set,
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
) => {
+ if (chatId && get(deletedChatIdsAtom).has(chatId)) {
+ return;
+ }
+
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
const existing = state.tabs.find((t) => t.id === tabId);
@@ -128,6 +135,19 @@ export const updateChatTabTitleAtom = atom(
(get, set, { chatId, title }: { chatId: number; title: string }) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
+ const hasExactTab = state.tabs.some((t) => t.id === tabId);
+
+ // During lazy thread creation, title updates can arrive before "chat-new"
+ // is swapped to chat-{id}. In that case, promote the active "chat-new" tab.
+ if (!hasExactTab && state.activeTabId === "chat-new") {
+ set(tabsStateAtom, {
+ ...state,
+ activeTabId: tabId,
+ tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
+ });
+ return;
+ }
+
set(tabsStateAtom, {
...state,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
@@ -213,7 +233,39 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => {
return remaining.find((t) => t.id === newActiveId) ?? null;
});
+/** Remove a chat tab by chat ID (used when a chat is deleted). */
+export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
+ const state = get(tabsStateAtom);
+ const tabId = makeChatTabId(chatId);
+ const idx = state.tabs.findIndex((t) => t.id === tabId);
+ if (idx === -1) return null;
+
+ const deletedChatIds = get(deletedChatIdsAtom);
+ set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
+
+ const remaining = state.tabs.filter((t) => t.id !== tabId);
+
+ // Always keep at least one tab available.
+ if (remaining.length === 0) {
+ set(tabsStateAtom, {
+ tabs: [INITIAL_CHAT_TAB],
+ activeTabId: "chat-new",
+ });
+ return INITIAL_CHAT_TAB;
+ }
+
+ let newActiveId = state.activeTabId;
+ if (state.activeTabId === tabId) {
+ const newIdx = Math.min(idx, remaining.length - 1);
+ newActiveId = remaining[newIdx].id;
+ }
+
+ set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
+ return remaining.find((t) => t.id === newActiveId) ?? null;
+});
+
/** Reset tabs when switching search spaces. */
export const resetTabsAtom = atom(null, (_get, set) => {
set(tabsStateAtom, { ...initialState });
+ set(deletedChatIdsAtom, new Set());
});
diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts
index b7289568b..8e196c9c7 100644
--- a/surfsense_web/atoms/user/user-query.atoms.ts
+++ b/surfsense_web/atoms/user/user-query.atoms.ts
@@ -1,16 +1,15 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { userApiService } from "@/lib/apis/user-api.service";
-import { getBearerToken, isPublicRoute } from "@/lib/auth-utils";
+import { getBearerToken } from "@/lib/auth-utils";
export const USER_QUERY_KEY = ["user", "me"] as const;
const userQueryFn = () => userApiService.getMe();
export const currentUserAtom = atomWithQuery(() => {
- const pathname = typeof window !== "undefined" ? window.location.pathname : null;
return {
queryKey: USER_QUERY_KEY,
staleTime: 5 * 60 * 1000,
- enabled: !!getBearerToken() && pathname !== null && !isPublicRoute(pathname),
+ enabled: !!getBearerToken(),
queryFn: userQueryFn,
};
});
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index 9fefecb1c..0dcaf6350 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -3,24 +3,41 @@ import {
AuiIf,
ErrorPrimitive,
MessagePrimitive,
+ useAui,
useAuiState,
} from "@assistant-ui/react";
import { useAtomValue } from "jotai";
-import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
+import {
+ CheckIcon,
+ ClipboardPaste,
+ CopyIcon,
+ DownloadIcon,
+ ExternalLink,
+ Globe,
+ MessageSquare,
+ RefreshCwIcon,
+} from "lucide-react";
+import dynamic from "next/dynamic";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import {
+ CitationMetadataProvider,
+ useAllCitationMetadata,
+} from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
+import type { SerializableCitation } from "@/components/tool-ui/citation";
import {
CreateConfluencePageToolUI,
DeleteConfluencePageToolUI,
UpdateConfluencePageToolUI,
} from "@/components/tool-ui/confluence";
+import { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "@/components/tool-ui/dropbox";
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
@@ -54,13 +71,173 @@ import {
DeleteNotionPageToolUI,
UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion";
+import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
+import {
+ openSafeNavigationHref,
+ resolveSafeNavigationHref,
+} from "@/components/tool-ui/shared/media";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
-import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
+// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
+const GenerateVideoPresentationToolUI = dynamic(
+ () =>
+ import("@/components/tool-ui/video-presentation").then((m) => ({
+ default: m.GenerateVideoPresentationToolUI,
+ })),
+ { ssr: false }
+);
+
+function extractDomain(url: string): string | undefined {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return undefined;
+ }
+}
+
+function useCitationsFromMetadata(): SerializableCitation[] {
+ const allCitations = useAllCitationMetadata();
+ return useMemo(() => {
+ const result: SerializableCitation[] = [];
+ for (const [url, meta] of allCitations) {
+ const domain = extractDomain(url);
+ result.push({
+ id: `url-cite-${url}`,
+ href: url,
+ title: meta.title,
+ snippet: meta.snippet,
+ domain,
+ favicon: domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : undefined,
+ type: "webpage",
+ });
+ }
+ return result;
+ }, [allCitations]);
+}
+
+const MobileCitationDrawer: FC = () => {
+ const [open, setOpen] = useState(false);
+ const citations = useCitationsFromMetadata();
+
+ if (citations.length === 0) return null;
+
+ const maxIcons = 4;
+ const visible = citations.slice(0, maxIcons);
+ const remainingCount = Math.max(0, citations.length - maxIcons);
+
+ const handleNavigate = (citation: SerializableCitation) => {
+ const href = resolveSafeNavigationHref(citation.href);
+ if (href) openSafeNavigationHref(href);
+ };
+
+ return (
+ <>
+ setOpen(true)}
+ className={cn(
+ "isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
+ "bg-muted/40 outline-none",
+ "transition-colors duration-150",
+ "hover:bg-muted/70",
+ "focus-visible:ring-ring focus-visible:ring-2"
+ )}
+ >
+
+ {visible.map((citation, index) => (
+
0 && "-ml-2"
+ )}
+ style={{ zIndex: maxIcons - index }}
+ >
+ {citation.favicon ? (
+ // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
+
+ ) : (
+
+ )}
+
+ ))}
+ {remainingCount > 0 && (
+
+
+ •••
+
+
+ )}
+
+
+ {citations.length} source{citations.length !== 1 && "s"}
+
+
+
+
+
+
+
+ Sources
+
+
+ {citations.map((citation) => (
+
handleNavigate(citation)}
+ className="group flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
+ >
+ {citation.favicon ? (
+ // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
+
+ ) : (
+
+ )}
+
+
+ {citation.title}
+
+
{citation.domain}
+
+
+
+ ))}
+
+
+
+ >
+ );
+};
+
export const MessageError: FC = () => {
return (
@@ -72,8 +249,10 @@ export const MessageError: FC = () => {
};
const AssistantMessageInner: FC = () => {
+ const isMobile = !useMediaQuery("(min-width: 768px)");
+
return (
- <>
+
{
delete_linear_issue: DeleteLinearIssueToolUI,
create_google_drive_file: CreateGoogleDriveFileToolUI,
delete_google_drive_file: DeleteGoogleDriveFileToolUI,
+ create_onedrive_file: CreateOneDriveFileToolUI,
+ delete_onedrive_file: DeleteOneDriveFileToolUI,
+ create_dropbox_file: CreateDropboxFileToolUI,
+ delete_dropbox_file: DeleteDropboxFileToolUI,
create_calendar_event: CreateCalendarEventToolUI,
update_calendar_event: UpdateCalendarEventToolUI,
delete_calendar_event: DeleteCalendarEventToolUI,
@@ -109,6 +292,7 @@ const AssistantMessageInner: FC = () => {
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
+ web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
@@ -120,10 +304,16 @@ const AssistantMessageInner: FC = () => {
+ {isMobile && (
+
+
+
+ )}
+
- >
+
);
};
@@ -272,6 +462,17 @@ export const AssistantMessage: FC = () => {
const AssistantActionBar: FC = () => {
const isLast = useAuiState((s) => s.message.isLast);
+ const aui = useAui();
+ const [quickAskMode, setQuickAskMode] = useState("");
+
+ useEffect(() => {
+ if (!isLast || !window.electronAPI?.getQuickAskMode) return;
+ window.electronAPI.getQuickAskMode().then((mode) => {
+ if (mode) setQuickAskMode(mode);
+ });
+ }, [isLast]);
+
+ const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
return (
{
- {/* Only allow regenerating the last assistant message */}
{isLast && (
@@ -303,6 +503,19 @@ const AssistantActionBar: FC = () => {
)}
+ {isTransform && (
+ {
+ const text = aui.message().getCopyText();
+ window.electronAPI?.replaceText(text);
+ }}
+ className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
+ >
+
+ Paste back
+
+ )}
);
};
diff --git a/surfsense_web/components/assistant-ui/citation-metadata-context.tsx b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
new file mode 100644
index 000000000..0bf5dd946
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useAuiState } from "@assistant-ui/react";
+import { createContext, type FC, type ReactNode, useContext, useMemo } from "react";
+
+export interface CitationMeta {
+ title: string;
+ snippet?: string;
+}
+
+type CitationMetadataMap = ReadonlyMap;
+
+const CitationMetadataContext = createContext(new Map());
+
+interface ToolCallResult {
+ status?: string;
+ citations?: Record;
+}
+
+interface MessageContent {
+ type: string;
+ toolName?: string;
+ result?: unknown;
+}
+
+export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
+ const content = useAuiState(
+ ({ message }) => (message as { content?: MessageContent[] })?.content
+ );
+
+ const metadataMap = useMemo(() => {
+ if (!content || !Array.isArray(content)) return new Map();
+
+ const merged = new Map();
+
+ for (const part of content) {
+ if (part.type !== "tool-call" || part.toolName !== "web_search" || !part.result) {
+ continue;
+ }
+
+ const result = part.result as ToolCallResult;
+ const citations = result.citations;
+ if (!citations || typeof citations !== "object") continue;
+
+ for (const [url, meta] of Object.entries(citations)) {
+ if (url.startsWith("http") && meta.title && !merged.has(url)) {
+ merged.set(url, { title: meta.title, snippet: meta.snippet });
+ }
+ }
+ }
+
+ return merged;
+ }, [content]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export function useCitationMetadata(url: string): CitationMeta | undefined {
+ const map = useContext(CitationMetadataContext);
+ return map.get(url);
+}
+
+export function useAllCitationMetadata(): CitationMetadataMap {
+ return useContext(CitationMetadataContext);
+}
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
index d4960bd29..d1f6dd31f 100644
--- a/surfsense_web/components/assistant-ui/connector-popup.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup.tsx
@@ -1,7 +1,7 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
-import { AlertTriangle, Cable, Settings } from "lucide-react";
+import { AlertTriangle, Settings } from "lucide-react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
@@ -12,17 +12,14 @@ import {
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
-import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
-import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
-import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
@@ -47,7 +44,7 @@ interface ConnectorIndicatorProps {
}
export const ConnectorIndicator = forwardRef(
- ({ showTrigger = true }, ref) => {
+ (_props, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
useAtomValue(currentUserAtom);
@@ -74,8 +71,6 @@ export const ConnectorIndicator = forwardRef count > 0)
@@ -205,41 +198,6 @@ export const ConnectorIndicator = forwardRef
- {showTrigger && (
- handleOpenChange(true)}
- >
- {isLoading ? (
-
- ) : (
- <>
-
- {activeConnectorsCount > 0 && (
-
- {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
-
- )}
- >
- )}
-
- )}
-
{isOpen &&
createPortal(
{
const cfg = connectorConfig || editingConnector.config;
- const isDrive =
+ const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
- editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
- const hasDriveItems = isDrive
+ editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "DROPBOX_CONNECTOR";
+ const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
index 4119b74cd..d24057b1c 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
@@ -143,7 +143,7 @@ export const ConnectorCard: FC = ({
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
- "h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium",
+ "relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center",
isConnected &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
!isConnected && "shadow-xs"
@@ -151,19 +151,18 @@ export const ConnectorCard: FC = ({
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || !isEnabled}
>
- {isConnecting ? (
-
- ) : !isEnabled ? (
- "Unavailable"
- ) : isConnected ? (
- "Manage"
- ) : id === "youtube-crawler" ? (
- "Add"
- ) : connectorType ? (
- "Connect"
- ) : (
- "Add"
- )}
+
+ {!isEnabled
+ ? "Unavailable"
+ : isConnected
+ ? "Manage"
+ : id === "youtube-crawler"
+ ? "Add"
+ : connectorType
+ ? "Connect"
+ : "Add"}
+
+ {isConnecting && }
);
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
index 2a5bb2121..58d365128 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
@@ -243,7 +243,7 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
- setShowDetails(!showDetails);
+ setShowDetails((prev) => !prev);
}}
>
{showDetails ? (
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/baidu-search-api-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/baidu-search-api-config.tsx
index f5a0bdf5f..4bf64afb9 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/baidu-search-api-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/baidu-search-api-config.tsx
@@ -2,7 +2,7 @@
import { KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -19,13 +19,6 @@ export const BaiduSearchApiConfig: FC = ({
const [apiKey, setApiKey] = useState((connector.config?.BAIDU_API_KEY as string) || "");
const [name, setName] = useState(connector.name || "");
- // Update API key and name when connector changes
- useEffect(() => {
- const key = (connector.config?.BAIDU_API_KEY as string) || "";
- setApiKey(key);
- setName(connector.name || "");
- }, [connector.config, connector.name]);
-
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/bookstack-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/bookstack-config.tsx
index ca287c652..2fcd8ab64 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/bookstack-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/bookstack-config.tsx
@@ -2,7 +2,7 @@
import { KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -27,17 +27,6 @@ export const BookStackConfig: FC = ({
);
const [name, setName] = useState(connector.name || "");
- // Update values when connector changes
- useEffect(() => {
- const url = (connector.config?.BOOKSTACK_BASE_URL as string) || "";
- const id = (connector.config?.BOOKSTACK_TOKEN_ID as string) || "";
- const secret = (connector.config?.BOOKSTACK_TOKEN_SECRET as string) || "";
- setBaseUrl(url);
- setTokenId(id);
- setTokenSecret(secret);
- setName(connector.name || "");
- }, [connector.config, connector.name]);
-
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
index 3ab9cba53..99e26c542 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
@@ -34,11 +34,6 @@ export const CirclebackConfig: FC = ({ connector, onNameC
const [isLoading, setIsLoading] = useState(true);
const [copied, setCopied] = useState(false);
- // Update name when connector changes
- useEffect(() => {
- setName(connector.name || "");
- }, [connector.name]);
-
// Fetch webhook info
useEffect(() => {
const fetchWebhookInfo = async () => {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/clickup-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/clickup-config.tsx
index 5b7ddaeb8..9d203a33b 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/clickup-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/clickup-config.tsx
@@ -2,7 +2,7 @@
import { Info, KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -24,15 +24,6 @@ export const ClickUpConfig: FC = ({
);
const [name, setName] = useState(connector.name || "");
- // Update values when connector changes (only for legacy connectors)
- useEffect(() => {
- if (!isOAuth) {
- const token = (connector.config?.CLICKUP_API_TOKEN as string) || "";
- setApiToken(token);
- }
- setName(connector.name || "");
- }, [connector.config, connector.name, isOAuth]);
-
const handleApiTokenChange = (value: string) => {
setApiToken(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx
index f7f490774..c08011bb5 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx
@@ -12,8 +12,8 @@ import {
X,
} from "lucide-react";
import type { FC } from "react";
-import { useCallback, useEffect, useState } from "react";
-import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
+import { useCallback, useState } from "react";
+import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -23,13 +23,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
+import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { ConnectorConfigProps } from "../index";
-interface SelectedFolder {
- id: string;
- name: string;
-}
-
interface IndexingOptions {
max_files_per_folder: number;
incremental_sync: boolean;
@@ -102,20 +98,19 @@ export const ComposioDriveConfig: FC = ({ connector, onCon
setAuthError(true);
}, []);
+ const fetchItems = useCallback(
+ async (parentId?: string) => {
+ return connectorsApiService.listComposioDriveFolders({
+ connector_id: connector.id,
+ parent_id: parentId,
+ });
+ },
+ [connector.id]
+ );
+
const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
- useEffect(() => {
- const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
- const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
- const options =
- (connector.config?.indexing_options as IndexingOptions | undefined) ||
- DEFAULT_INDEXING_OPTIONS;
- setSelectedFolders(folders);
- setSelectedFiles(files);
- setIndexingOptions(options);
- }, [connector.config]);
-
const updateConfig = (
folders: SelectedFolder[],
files: SelectedFolder[],
@@ -244,7 +239,7 @@ export const ComposioDriveConfig: FC = ({ connector, onCon
setIsFolderTreeOpen(!isFolderTreeOpen)}
+ onClick={() => setIsFolderTreeOpen((prev) => !prev)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
Change Selection
@@ -255,24 +250,28 @@ export const ComposioDriveConfig: FC = ({ connector, onCon
)}
{isFolderTreeOpen && (
-
)}
) : (
-
)}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx
index 59fa89554..040479211 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx
@@ -2,7 +2,7 @@
import { Info, KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -28,19 +28,6 @@ export const ConfluenceConfig: FC = ({
);
const [name, setName] = useState(connector.name || "");
- // Update values when connector changes (only for legacy connectors)
- useEffect(() => {
- if (!isOAuth) {
- const url = (connector.config?.CONFLUENCE_BASE_URL as string) || "";
- const emailVal = (connector.config?.CONFLUENCE_EMAIL as string) || "";
- const token = (connector.config?.CONFLUENCE_API_TOKEN as string) || "";
- setBaseUrl(url);
- setEmail(emailVal);
- setApiToken(token);
- }
- setName(connector.name || "");
- }, [connector.config, connector.name, isOAuth]);
-
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/dropbox-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/dropbox-config.tsx
new file mode 100644
index 000000000..ce793a1a7
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/dropbox-config.tsx
@@ -0,0 +1,344 @@
+"use client";
+
+import {
+ ChevronDown,
+ ChevronRight,
+ File,
+ FileSpreadsheet,
+ FileText,
+ FolderClosed,
+ Image,
+ Presentation,
+ X,
+} from "lucide-react";
+import type { FC } from "react";
+import { useCallback, useEffect, useState } from "react";
+import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { connectorsApiService } from "@/lib/apis/connectors-api.service";
+import type { ConnectorConfigProps } from "../index";
+
+interface IndexingOptions {
+ max_files_per_folder: number;
+ incremental_sync: boolean;
+ include_subfolders: boolean;
+}
+
+const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
+ max_files_per_folder: 100,
+ incremental_sync: true,
+ include_subfolders: true,
+};
+
+function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
+ const lowerName = fileName.toLowerCase();
+ if (lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls") || lowerName.endsWith(".csv")) {
+ return ;
+ }
+ if (lowerName.endsWith(".pptx") || lowerName.endsWith(".ppt")) {
+ return ;
+ }
+ if (lowerName.endsWith(".docx") || lowerName.endsWith(".doc") || lowerName.endsWith(".txt")) {
+ return ;
+ }
+ if (/\.(png|jpe?g|gif|webp|svg)$/.test(lowerName)) {
+ return ;
+ }
+ return ;
+}
+
+export const DropboxConfig: FC = ({ connector, onConfigChange }) => {
+ const existingFolders =
+ (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
+ const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
+ const existingIndexingOptions =
+ (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
+
+ const [selectedFolders, setSelectedFolders] = useState(existingFolders);
+ const [selectedFiles, setSelectedFiles] = useState(existingFiles);
+ const [indexingOptions, setIndexingOptions] = useState(existingIndexingOptions);
+ const [authError, setAuthError] = useState(false);
+
+ const isAuthExpired = connector.config?.auth_expired === true || authError;
+
+ const handleAuthError = useCallback(() => {
+ setAuthError(true);
+ }, []);
+
+ const fetchItems = useCallback(
+ async (parentId?: string) => {
+ return connectorsApiService.listDropboxFolders({
+ connector_id: connector.id,
+ parent_path: parentId,
+ });
+ },
+ [connector.id]
+ );
+
+ const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
+ const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
+
+ useEffect(() => {
+ const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
+ const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
+ const options =
+ (connector.config?.indexing_options as IndexingOptions | undefined) ||
+ DEFAULT_INDEXING_OPTIONS;
+ setSelectedFolders(folders);
+ setSelectedFiles(files);
+ setIndexingOptions(options);
+ }, [connector.config]);
+
+ const updateConfig = (
+ folders: SelectedFolder[],
+ files: SelectedFolder[],
+ options: IndexingOptions
+ ) => {
+ if (onConfigChange) {
+ onConfigChange({
+ ...connector.config,
+ selected_folders: folders,
+ selected_files: files,
+ indexing_options: options,
+ });
+ }
+ };
+
+ const handleSelectFolders = (folders: SelectedFolder[]) => {
+ setSelectedFolders(folders);
+ updateConfig(folders, selectedFiles, indexingOptions);
+ };
+
+ const handleSelectFiles = (files: SelectedFolder[]) => {
+ setSelectedFiles(files);
+ updateConfig(selectedFolders, files, indexingOptions);
+ };
+
+ const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
+ const newOptions = { ...indexingOptions, [key]: value };
+ setIndexingOptions(newOptions);
+ updateConfig(selectedFolders, selectedFiles, newOptions);
+ };
+
+ const handleRemoveFolder = (folderId: string) => {
+ const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
+ setSelectedFolders(newFolders);
+ updateConfig(newFolders, selectedFiles, indexingOptions);
+ };
+
+ const handleRemoveFile = (fileId: string) => {
+ const newFiles = selectedFiles.filter((file) => file.id !== fileId);
+ setSelectedFiles(newFiles);
+ updateConfig(selectedFolders, newFiles, indexingOptions);
+ };
+
+ const totalSelected = selectedFolders.length + selectedFiles.length;
+
+ return (
+
+
+
+
Folder & File Selection
+
+ Select specific folders and/or individual files to index from your Dropbox.
+
+
+
+ {totalSelected > 0 && (
+
+
+ Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => {
+ const parts: string[] = [];
+ if (selectedFolders.length > 0) {
+ parts.push(
+ `${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`
+ );
+ }
+ if (selectedFiles.length > 0) {
+ parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
+ }
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
+ })()}
+
+
+ {selectedFolders.map((folder) => (
+
+
+ {folder.name}
+ handleRemoveFolder(folder.id)}
+ className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
+ aria-label={`Remove ${folder.name}`}
+ >
+
+
+
+ ))}
+ {selectedFiles.map((file) => (
+
+ {getFileIconFromName(file.name)}
+ {file.name}
+ handleRemoveFile(file.id)}
+ className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
+ aria-label={`Remove ${file.name}`}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ {isAuthExpired && (
+
+ Your Dropbox authentication has expired. Please re-authenticate using the button below.
+
+ )}
+
+ {isEditMode ? (
+
+ setIsFolderTreeOpen(!isFolderTreeOpen)}
+ className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
+ >
+ Change Selection
+ {isFolderTreeOpen ? (
+
+ ) : (
+
+ )}
+
+ {isFolderTreeOpen && (
+
+ )}
+
+ ) : (
+
+ )}
+
+
+
+
+
Indexing Options
+
+ Configure how files are indexed from your Dropbox.
+
+
+
+
+
+
+
+ Max files per folder
+
+
+ Maximum number of files to index from each folder
+
+
+
+ handleIndexingOptionChange("max_files_per_folder", parseInt(value, 10))
+ }
+ >
+
+
+
+
+
+ 50 files
+
+
+ 100 files
+
+
+ 250 files
+
+
+ 500 files
+
+
+ 1000 files
+
+
+
+
+
+
+
+
+
+ Incremental sync
+
+
+ Only sync changes since last index (faster). Disable for a full re-index.
+
+
+
handleIndexingOptionChange("incremental_sync", checked)}
+ />
+
+
+
+
+
+ Include subfolders
+
+
+ Recursively index files in subfolders of selected folders
+
+
+
handleIndexingOptionChange("include_subfolders", checked)}
+ />
+
+
+
+ );
+};
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx
index c5123e922..cf96e9e59 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/elasticsearch-config.tsx
@@ -2,7 +2,7 @@
import { KeyRound, Server } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useId, useRef, useState } from "react";
+import { useId, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -56,38 +56,6 @@ export const ElasticsearchConfig: FC = ({
: ""
);
- // Update values when the connector identity changes (e.g. switching to a different connector)
- const connectorIdRef = useRef(connector.id);
- useEffect(() => {
- if (connectorIdRef.current === connector.id) return;
- connectorIdRef.current = connector.id;
-
- setName(connector.name || "");
- setEndpointUrl((connector.config?.ELASTICSEARCH_URL as string) || "");
- setAuthMethod(
- (connector.config?.ELASTICSEARCH_API_KEY ? "api_key" : "basic") as "basic" | "api_key"
- );
- setUsername((connector.config?.ELASTICSEARCH_USERNAME as string) || "");
- setPassword((connector.config?.ELASTICSEARCH_PASSWORD as string) || "");
- setApiKey((connector.config?.ELASTICSEARCH_API_KEY as string) || "");
- setIndices(
- Array.isArray(connector.config?.ELASTICSEARCH_INDEX)
- ? (connector.config?.ELASTICSEARCH_INDEX as string[]).join(", ")
- : (connector.config?.ELASTICSEARCH_INDEX as string) || ""
- );
- setQuery((connector.config?.ELASTICSEARCH_QUERY as string) || "*");
- setSearchFields(
- Array.isArray(connector.config?.ELASTICSEARCH_FIELDS)
- ? (connector.config?.ELASTICSEARCH_FIELDS as string[]).join(", ")
- : ""
- );
- setMaxDocuments(
- connector.config?.ELASTICSEARCH_MAX_DOCUMENTS
- ? String(connector.config.ELASTICSEARCH_MAX_DOCUMENTS)
- : ""
- );
- }, [connector]);
-
const stringToArray = (str: string): string[] => {
const items = str
.split(",")
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/github-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/github-config.tsx
index 2c28758b8..9288e6cbd 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/github-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/github-config.tsx
@@ -2,7 +2,7 @@
import { KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useRef, useState } from "react";
+import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -33,9 +33,6 @@ export const GithubConfig: FC = ({
onConfigChange,
onNameChange,
}) => {
- // Track internal changes to prevent useEffect from overwriting user input
- const isInternalChange = useRef(false);
-
const [githubPat, setGithubPat] = useState(
(connector.config?.GITHUB_PAT as string) || ""
);
@@ -44,22 +41,7 @@ export const GithubConfig: FC = ({
);
const [name, setName] = useState(connector.name || "");
- // Update values when connector changes externally (not from our own input)
- useEffect(() => {
- // Skip if this is our own internal change
- if (isInternalChange.current) {
- isInternalChange.current = false;
- return;
- }
- const pat = (connector.config?.GITHUB_PAT as string) || "";
- const repos = arrayToString(stringToArray(connector.config?.repo_full_names));
- setGithubPat(pat);
- setRepoFullNames(repos);
- setName(connector.name || "");
- }, [connector.config, connector.name]);
-
const handleGithubPatChange = (value: string) => {
- isInternalChange.current = true;
setGithubPat(value);
if (onConfigChange) {
onConfigChange({
@@ -70,7 +52,6 @@ export const GithubConfig: FC = ({
};
const handleRepoFullNamesChange = (value: string) => {
- isInternalChange.current = true;
setRepoFullNames(value);
const repoList = stringToArray(value);
if (onConfigChange) {
@@ -82,7 +63,6 @@ export const GithubConfig: FC = ({
};
const handleNameChange = (value: string) => {
- isInternalChange.current = true;
setName(value);
if (onNameChange) {
onNameChange(value);
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx
index 6b01df9f8..c73eed788 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx
@@ -10,7 +10,7 @@ import {
X,
} from "lucide-react";
import type { FC } from "react";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
@@ -92,17 +92,6 @@ export const GoogleDriveConfig: FC = ({ connector, onConfi
const [selectedFiles, setSelectedFiles] = useState(existingFiles);
const [indexingOptions, setIndexingOptions] = useState(existingIndexingOptions);
- useEffect(() => {
- const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || [];
- const files = (connector.config?.selected_files as SelectedItem[] | undefined) || [];
- const options =
- (connector.config?.indexing_options as IndexingOptions | undefined) ||
- DEFAULT_INDEXING_OPTIONS;
- setSelectedFolders(folders);
- setSelectedFiles(files);
- setIndexingOptions(options);
- }, [connector.config]);
-
const updateConfig = (
folders: SelectedItem[],
files: SelectedItem[],
@@ -242,8 +231,6 @@ export const GoogleDriveConfig: FC = ({ connector, onConfi
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
- {pickerError && !isAuthExpired &&
{pickerError}
}
-
{isAuthExpired && (
Your Google Drive authentication has expired. Please re-authenticate using the button
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx
index dcc83c2d6..c43bd1524 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx
@@ -2,7 +2,7 @@
import { Info, KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -22,19 +22,6 @@ export const JiraConfig: FC = ({ connector, onConfigChange, onN
);
const [name, setName] = useState(connector.name || "");
- // Update values when connector changes (only for legacy connectors)
- useEffect(() => {
- if (!isOAuth) {
- const url = (connector.config?.JIRA_BASE_URL as string) || "";
- const emailVal = (connector.config?.JIRA_EMAIL as string) || "";
- const token = (connector.config?.JIRA_API_TOKEN as string) || "";
- setBaseUrl(url);
- setEmail(emailVal);
- setApiToken(token);
- }
- setName(connector.name || "");
- }, [connector.config, connector.name, isOAuth]);
-
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/linkup-api-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/linkup-api-config.tsx
index 6421db4ac..65e4a3f88 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/linkup-api-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/linkup-api-config.tsx
@@ -2,7 +2,7 @@
import { KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -19,13 +19,6 @@ export const LinkupApiConfig: FC = ({
const [apiKey, setApiKey] = useState((connector.config?.LINKUP_API_KEY as string) || "");
const [name, setName] = useState(connector.name || "");
- // Update API key and name when connector changes
- useEffect(() => {
- const key = (connector.config?.LINKUP_API_KEY as string) || "";
- setApiKey(key);
- setName(connector.name || "");
- }, [connector.config, connector.name]);
-
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/luma-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/luma-config.tsx
index a2c29f442..2035b5561 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/luma-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/luma-config.tsx
@@ -2,7 +2,7 @@
import { KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -15,13 +15,6 @@ export const LumaConfig: FC = ({ connector, onConfigChange, onN
const [apiKey, setApiKey] = useState((connector.config?.LUMA_API_KEY as string) || "");
const [name, setName] = useState(connector.name || "");
- // Update API key and name when connector changes
- useEffect(() => {
- const key = (connector.config?.LUMA_API_KEY as string) || "";
- setApiKey(key);
- setName(connector.name || "");
- }, [connector.config, connector.name]);
-
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
index 38d60d7bd..ca997a9ba 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
@@ -248,7 +248,7 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
- setShowDetails(!showDetails);
+ setShowDetails((prev) => !prev);
}}
>
{showDetails ? (
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx
index 028c6bd83..3da1d6e7e 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx
@@ -1,7 +1,7 @@
"use client";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -34,25 +34,6 @@ export const ObsidianConfig: FC = ({
);
const [name, setName] = useState(connector.name || "");
- // Update values when connector changes
- useEffect(() => {
- const path = (connector.config?.vault_path as string) || "";
- const vName = (connector.config?.vault_name as string) || "";
- const folders = connector.config?.exclude_folders;
- const attachments = (connector.config?.include_attachments as boolean) || false;
-
- setVaultPath(path);
- setVaultName(vName);
- setIncludeAttachments(attachments);
- setName(connector.name || "");
-
- if (Array.isArray(folders)) {
- setExcludeFolders(folders.join(", "));
- } else if (typeof folders === "string") {
- setExcludeFolders(folders);
- }
- }, [connector.config, connector.name]);
-
const handleVaultPathChange = (value: string) => {
setVaultPath(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx
new file mode 100644
index 000000000..b835dbcec
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import {
+ ChevronDown,
+ ChevronRight,
+ File,
+ FileSpreadsheet,
+ FileText,
+ FolderClosed,
+ Image,
+ Presentation,
+ X,
+} from "lucide-react";
+import type { FC } from "react";
+import { useCallback, useEffect, useState } from "react";
+import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { connectorsApiService } from "@/lib/apis/connectors-api.service";
+import type { ConnectorConfigProps } from "../index";
+
+interface IndexingOptions {
+ max_files_per_folder: number;
+ incremental_sync: boolean;
+ include_subfolders: boolean;
+}
+
+const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
+ max_files_per_folder: 100,
+ incremental_sync: true,
+ include_subfolders: true,
+};
+
+function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
+ const lowerName = fileName.toLowerCase();
+ if (lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls") || lowerName.endsWith(".csv")) {
+ return ;
+ }
+ if (lowerName.endsWith(".pptx") || lowerName.endsWith(".ppt")) {
+ return ;
+ }
+ if (lowerName.endsWith(".docx") || lowerName.endsWith(".doc") || lowerName.endsWith(".txt")) {
+ return ;
+ }
+ if (/\.(png|jpe?g|gif|webp|svg)$/.test(lowerName)) {
+ return ;
+ }
+ return ;
+}
+
+export const OneDriveConfig: FC = ({ connector, onConfigChange }) => {
+ const existingFolders =
+ (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
+ const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
+ const existingIndexingOptions =
+ (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
+
+ const [selectedFolders, setSelectedFolders] = useState(existingFolders);
+ const [selectedFiles, setSelectedFiles] = useState(existingFiles);
+ const [indexingOptions, setIndexingOptions] = useState(existingIndexingOptions);
+ const [authError, setAuthError] = useState(false);
+
+ const isAuthExpired = connector.config?.auth_expired === true || authError;
+
+ const handleAuthError = useCallback(() => {
+ setAuthError(true);
+ }, []);
+
+ const fetchItems = useCallback(
+ async (parentId?: string) => {
+ return connectorsApiService.listOneDriveFolders({
+ connector_id: connector.id,
+ parent_id: parentId,
+ });
+ },
+ [connector.id]
+ );
+
+ const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
+ const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
+
+ useEffect(() => {
+ const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
+ const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
+ const options =
+ (connector.config?.indexing_options as IndexingOptions | undefined) ||
+ DEFAULT_INDEXING_OPTIONS;
+ setSelectedFolders(folders);
+ setSelectedFiles(files);
+ setIndexingOptions(options);
+ }, [connector.config]);
+
+ const updateConfig = (
+ folders: SelectedFolder[],
+ files: SelectedFolder[],
+ options: IndexingOptions
+ ) => {
+ if (onConfigChange) {
+ onConfigChange({
+ ...connector.config,
+ selected_folders: folders,
+ selected_files: files,
+ indexing_options: options,
+ });
+ }
+ };
+
+ const handleSelectFolders = (folders: SelectedFolder[]) => {
+ setSelectedFolders(folders);
+ updateConfig(folders, selectedFiles, indexingOptions);
+ };
+
+ const handleSelectFiles = (files: SelectedFolder[]) => {
+ setSelectedFiles(files);
+ updateConfig(selectedFolders, files, indexingOptions);
+ };
+
+ const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
+ const newOptions = { ...indexingOptions, [key]: value };
+ setIndexingOptions(newOptions);
+ updateConfig(selectedFolders, selectedFiles, newOptions);
+ };
+
+ const handleRemoveFolder = (folderId: string) => {
+ const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
+ setSelectedFolders(newFolders);
+ updateConfig(newFolders, selectedFiles, indexingOptions);
+ };
+
+ const handleRemoveFile = (fileId: string) => {
+ const newFiles = selectedFiles.filter((file) => file.id !== fileId);
+ setSelectedFiles(newFiles);
+ updateConfig(selectedFolders, newFiles, indexingOptions);
+ };
+
+ const totalSelected = selectedFolders.length + selectedFiles.length;
+
+ return (
+
+ {/* Folder & File Selection */}
+
+
+
Folder & File Selection
+
+ Select specific folders and/or individual files to index from your OneDrive.
+
+
+
+ {totalSelected > 0 && (
+
+
+ Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => {
+ const parts: string[] = [];
+ if (selectedFolders.length > 0) {
+ parts.push(
+ `${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`
+ );
+ }
+ if (selectedFiles.length > 0) {
+ parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
+ }
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
+ })()}
+
+
+ {selectedFolders.map((folder) => (
+
+
+ {folder.name}
+ handleRemoveFolder(folder.id)}
+ className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
+ aria-label={`Remove ${folder.name}`}
+ >
+
+
+
+ ))}
+ {selectedFiles.map((file) => (
+
+ {getFileIconFromName(file.name)}
+ {file.name}
+ handleRemoveFile(file.id)}
+ className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
+ aria-label={`Remove ${file.name}`}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ {isAuthExpired && (
+
+ Your OneDrive authentication has expired. Please re-authenticate using the button below.
+
+ )}
+
+ {isEditMode ? (
+
+ setIsFolderTreeOpen((prev) => !prev)}
+ className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
+ >
+ Change Selection
+ {isFolderTreeOpen ? (
+
+ ) : (
+
+ )}
+
+ {isFolderTreeOpen && (
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ {/* Indexing Options */}
+
+
+
Indexing Options
+
+ Configure how files are indexed from your OneDrive.
+
+
+
+ {/* Max files per folder */}
+
+
+
+
+ Max files per folder
+
+
+ Maximum number of files to index from each folder
+
+
+
+ handleIndexingOptionChange("max_files_per_folder", parseInt(value, 10))
+ }
+ >
+
+
+
+
+
+ 50 files
+
+
+ 100 files
+
+
+ 250 files
+
+
+ 500 files
+
+
+ 1000 files
+
+
+
+
+
+
+ {/* Incremental sync toggle */}
+
+
+
+ Incremental sync
+
+
+ Only sync changes since last index (faster). Disable for a full re-index.
+
+
+
handleIndexingOptionChange("incremental_sync", checked)}
+ />
+
+
+ {/* Include subfolders toggle */}
+
+
+
+ Include subfolders
+
+
+ Recursively index files in subfolders of selected folders
+
+
+
handleIndexingOptionChange("include_subfolders", checked)}
+ />
+
+
+
+ );
+};
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/searxng-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/searxng-config.tsx
index 4f6bde5ee..3f920eb8d 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/searxng-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/searxng-config.tsx
@@ -2,7 +2,7 @@
import { Globe, KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -55,32 +55,6 @@ export const SearxngConfig: FC = ({
);
const [name, setName] = useState(connector.name || "");
- // Update all fields when connector changes
- useEffect(() => {
- const hostValue = (connector.config?.SEARXNG_HOST as string) || "";
- const apiKeyValue = (connector.config?.SEARXNG_API_KEY as string) || "";
- const enginesValue = arrayToString(connector.config?.SEARXNG_ENGINES);
- const categoriesValue = arrayToString(connector.config?.SEARXNG_CATEGORIES);
- const languageValue = (connector.config?.SEARXNG_LANGUAGE as string) || "";
- const safesearchValue =
- connector.config?.SEARXNG_SAFESEARCH !== undefined
- ? String(connector.config.SEARXNG_SAFESEARCH)
- : "";
- const verifySslValue =
- connector.config?.SEARXNG_VERIFY_SSL !== undefined
- ? (connector.config.SEARXNG_VERIFY_SSL as boolean)
- : true;
-
- setHost(hostValue);
- setApiKey(apiKeyValue);
- setEngines(enginesValue);
- setCategories(categoriesValue);
- setLanguage(languageValue);
- setSafesearch(safesearchValue);
- setVerifySsl(verifySslValue);
- setName(connector.name || "");
- }, [connector.config, connector.name]);
-
const updateConfig = (updates: Record) => {
if (onConfigChange) {
onConfigChange({
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/tavily-api-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/tavily-api-config.tsx
index de5cb0b5b..34143d02b 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/tavily-api-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/tavily-api-config.tsx
@@ -2,7 +2,7 @@
import { KeyRound } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@@ -19,13 +19,6 @@ export const TavilyApiConfig: FC = ({
const [apiKey, setApiKey] = useState((connector.config?.TAVILY_API_KEY as string) || "");
const [name, setName] = useState(connector.name || "");
- // Update API key and name when connector changes
- useEffect(() => {
- const key = (connector.config?.TAVILY_API_KEY as string) || "";
- setApiKey(key);
- setName(connector.name || "");
- }, [connector.config, connector.name]);
-
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx
index 164d78e09..a01cde15f 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx
@@ -2,7 +2,7 @@
import { Info } from "lucide-react";
import type { FC } from "react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,14 +19,6 @@ export const WebcrawlerConfig: FC = ({ connector, onConfig
const [initialUrls, setInitialUrls] = useState(existingUrls);
const [showApiKey, setShowApiKey] = useState(false);
- // Update state when connector config changes
- useEffect(() => {
- const apiKeyValue = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || "";
- const urlsValue = (connector.config?.INITIAL_URLS as string | undefined) || "";
- setApiKey(apiKeyValue);
- setInitialUrls(urlsValue);
- }, [connector.config]);
-
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
@@ -86,7 +78,7 @@ export const WebcrawlerConfig: FC = ({ connector, onConfig
type="button"
variant="ghost"
size="sm"
- onClick={() => setShowApiKey(!showApiKey)}
+ onClick={() => setShowApiKey((prev) => !prev)}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
{showApiKey ? "Hide" : "Show"}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
index cef0c99ac..a63435260 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
@@ -11,6 +11,7 @@ import { ComposioDriveConfig } from "./components/composio-drive-config";
import { ComposioGmailConfig } from "./components/composio-gmail-config";
import { ConfluenceConfig } from "./components/confluence-config";
import { DiscordConfig } from "./components/discord-config";
+import { DropboxConfig } from "./components/dropbox-config";
import { ElasticsearchConfig } from "./components/elasticsearch-config";
import { GithubConfig } from "./components/github-config";
import { GoogleDriveConfig } from "./components/google-drive-config";
@@ -19,6 +20,7 @@ import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config";
+import { OneDriveConfig } from "./components/onedrive-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
import { TeamsConfig } from "./components/teams-config";
@@ -58,6 +60,10 @@ export function getConnectorConfigComponent(
return DiscordConfig;
case "TEAMS_CONNECTOR":
return TeamsConfig;
+ case "DROPBOX_CONNECTOR":
+ return DropboxConfig;
+ case "ONEDRIVE_CONNECTOR":
+ return OneDriveConfig;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR":
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
index 93d280a15..20d4a8e53 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
@@ -27,6 +27,8 @@ const REAUTH_ENDPOINTS: Partial> = {
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
+ [EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
+ [EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
};
interface ConnectorEditViewProps {
@@ -256,6 +258,7 @@ export const ConnectorEditView: FC = ({
{/* Connector-specific configuration */}
{ConnectorConfigComponent && (
= ({
{/* AI Summary toggle */}
- {/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
+ {/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
+ connector.connector_type !== "DROPBOX_CONNECTOR" &&
+ connector.connector_type !== "ONEDRIVE_CONNECTOR" &&
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
connector.connector_type !== "GITHUB_CONNECTOR" && (
= ({
{/* AI Summary toggle */}
- {/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
+ {/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
+ config.connectorType !== "DROPBOX_CONNECTOR" &&
+ config.connectorType !== "ONEDRIVE_CONNECTOR" &&
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
config.connectorType !== "GITHUB_CONNECTOR" && (
= ({
- {isStartingIndexing ? (
- <>
-
- Starting
- >
- ) : (
- "Start Indexing"
- )}
+ Start Indexing
+ {isStartingIndexing && }
diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
index ab69d4ca2..2e92f637b 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
@@ -61,6 +61,20 @@ export const OAUTH_CONNECTORS = [
connectorType: EnumConnectorName.TEAMS_CONNECTOR,
authEndpoint: "/api/v1/auth/teams/connector/add/",
},
+ {
+ id: "onedrive-connector",
+ title: "OneDrive",
+ description: "Search your OneDrive files",
+ connectorType: EnumConnectorName.ONEDRIVE_CONNECTOR,
+ authEndpoint: "/api/v1/auth/onedrive/connector/add/",
+ },
+ {
+ id: "dropbox-connector",
+ title: "Dropbox",
+ description: "Search your Dropbox files",
+ connectorType: EnumConnectorName.DROPBOX_CONNECTOR,
+ authEndpoint: "/api/v1/auth/dropbox/connector/add/",
+ },
{
id: "discord-connector",
title: "Discord",
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index 03d8a8fb4..6543bbd72 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -729,10 +729,12 @@ export const useConnectorDialog = () => {
async (refreshConnectors: () => void) => {
if (!indexingConfig || !searchSpaceId) return;
- // Validate date range (skip for Google Drive, Composio Drive, and Webcrawler)
+ // Validate date range (skip for Google Drive, Composio Drive, OneDrive, Dropbox, and Webcrawler)
if (
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
+ indexingConfig.connectorType !== "ONEDRIVE_CONNECTOR" &&
+ indexingConfig.connectorType !== "DROPBOX_CONNECTOR" &&
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
) {
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
@@ -778,10 +780,12 @@ export const useConnectorDialog = () => {
});
}
- // Handle Google Drive folder selection (regular and Composio)
+ // Handle Google Drive / OneDrive / Dropbox folder selection (regular and Composio)
if (
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
- indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR") &&
+ indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
+ indexingConfig.connectorType === "ONEDRIVE_CONNECTOR" ||
+ indexingConfig.connectorType === "DROPBOX_CONNECTOR") &&
indexingConnectorConfig
) {
const selectedFolders = indexingConnectorConfig.selected_folders as
@@ -967,10 +971,12 @@ export const useConnectorDialog = () => {
async (refreshConnectors: () => void) => {
if (!editingConnector || !searchSpaceId || isSaving) return;
- // Validate date range (skip for Google Drive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
+ // Validate date range (skip for Google Drive/OneDrive/Dropbox which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
if (
editingConnector.is_indexable &&
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
+ editingConnector.connector_type !== "ONEDRIVE_CONNECTOR" &&
+ editingConnector.connector_type !== "DROPBOX_CONNECTOR" &&
editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR"
) {
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
@@ -986,11 +992,13 @@ export const useConnectorDialog = () => {
return;
}
- // Prevent periodic indexing for Google Drive (regular or Composio) without folders/files selected
+ // Prevent periodic indexing for Google Drive / OneDrive / Dropbox (regular or Composio) without folders/files selected
if (
periodicEnabled &&
(editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
- editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR")
+ editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "DROPBOX_CONNECTOR")
) {
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }>
@@ -1043,9 +1051,11 @@ export const useConnectorDialog = () => {
indexingDescription = "Settings saved.";
} else if (
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
- editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
+ editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "DROPBOX_CONNECTOR"
) {
- // Google Drive (both regular and Composio) uses folder selection from config, not date ranges
+ // Google Drive (both regular and Composio) / OneDrive / Dropbox uses folder selection from config, not date ranges
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }>
| undefined;
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
index 0268ab761..ad2418865 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
@@ -13,6 +13,24 @@ import {
} from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
+type OAuthConnector = (typeof OAUTH_CONNECTORS)[number];
+type ComposioConnector = (typeof COMPOSIO_CONNECTORS)[number];
+type OtherConnector = (typeof OTHER_CONNECTORS)[number];
+type CrawlerConnector = (typeof CRAWLERS)[number];
+
+const DOCUMENT_FILE_CONNECTOR_TYPES = new Set