= ({
{getConnectorTypeDisplay(connector?.connector_type || "")} Connected !
- {" "}
-
- {getConnectorDisplayName(connector?.name || "")}
+ {connector?.name?.includes(" - ") && (
+
+ {getConnectorDisplayName(connector.name)}
+
+ )}
Configure when to start syncing your data
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
index 437e5a7a5..b8194c91f 100644
--- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
+++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
@@ -27,6 +27,12 @@ export interface InlineMentionEditorRef {
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick) => void;
+ setDocumentChipStatus: (
+ docId: number,
+ docType: string | undefined,
+ statusLabel: string | null,
+ statusKind?: "pending" | "processing" | "ready" | "failed"
+ ) => void;
}
interface InlineMentionEditorProps {
@@ -46,6 +52,7 @@ interface InlineMentionEditorProps {
const CHIP_DATA_ATTR = "data-mention-chip";
const CHIP_ID_ATTR = "data-mention-id";
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
+const CHIP_STATUS_ATTR = "data-mention-status";
/**
* Type guard to check if a node is a chip element
@@ -182,6 +189,11 @@ export const InlineMentionEditor = forwardRef {
+ if (!editorRef.current) return;
+
+ const chips = editorRef.current.querySelectorAll(
+ `span[${CHIP_DATA_ATTR}="true"]`
+ );
+ for (const chip of chips) {
+ const chipId = getChipId(chip);
+ const chipType = getChipDocType(chip);
+ if (chipId !== docId) continue;
+ if ((docType ?? "UNKNOWN") !== chipType) continue;
+
+ const statusEl = chip.querySelector(`span[${CHIP_STATUS_ATTR}="true"]`);
+ if (!statusEl) continue;
+
+ if (!statusLabel) {
+ statusEl.textContent = "";
+ statusEl.className = "text-[10px] font-semibold opacity-80 hidden";
+ continue;
+ }
+
+ const statusClass =
+ statusKind === "failed"
+ ? "text-destructive"
+ : statusKind === "processing"
+ ? "text-amber-700"
+ : statusKind === "ready"
+ ? "text-emerald-700"
+ : "text-amber-700";
+ statusEl.textContent = statusLabel;
+ statusEl.className = `text-[10px] font-semibold opacity-80 ${statusClass}`;
+ }
+ },
+ []
+ );
+
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
@@ -339,6 +394,7 @@ export const InlineMentionEditor = forwardRef;
header?: React.ReactNode;
@@ -230,8 +246,13 @@ const Composer: FC = () => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
+ const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
+ Record
+ >({});
+ const [isUploadingDocs, setIsUploadingDocs] = useState(false);
const editorRef = useRef(null);
const editorContainerRef = useRef(null);
+ const uploadInputRef = useRef(null);
const documentPickerRef = useRef(null);
const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
@@ -357,9 +378,28 @@ const Composer: FC = () => {
[showDocumentPopover]
);
+ const uploadedMentionedDocs = useMemo(
+ () => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
+ [mentionedDocuments, uploadedMentionDocs]
+ );
+
+ const blockingUploadedMentions = useMemo(
+ () =>
+ uploadedMentionedDocs.filter((doc) => {
+ const state = uploadedMentionDocs[doc.id]?.state;
+ return state === "pending" || state === "processing" || state === "failed";
+ }),
+ [uploadedMentionedDocs, uploadedMentionDocs]
+ );
+
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => {
- if (isThreadRunning || isBlockedByOtherUser) {
+ if (
+ isThreadRunning ||
+ isBlockedByOtherUser ||
+ isUploadingDocs ||
+ blockingUploadedMentions.length > 0
+ ) {
return;
}
if (!showDocumentPopover) {
@@ -375,6 +415,8 @@ const Composer: FC = () => {
showDocumentPopover,
isThreadRunning,
isBlockedByOtherUser,
+ isUploadingDocs,
+ blockingUploadedMentions.length,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
@@ -395,6 +437,11 @@ const Composer: FC = () => {
});
return updated;
});
+ setUploadedMentionDocs((prev) => {
+ if (!(docId in prev)) return prev;
+ const { [docId]: _removed, ...rest } = prev;
+ return rest;
+ });
},
[setMentionedDocuments, setMentionedDocumentIds]
);
@@ -433,6 +480,139 @@ const Composer: FC = () => {
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
);
+ const refreshUploadedDocStatuses = useCallback(
+ async (documentIds: number[]) => {
+ if (!search_space_id || documentIds.length === 0) return;
+ const statusResponse = await documentsApiService.getDocumentsStatus({
+ queryParams: {
+ search_space_id: Number(search_space_id),
+ document_ids: documentIds,
+ },
+ });
+
+ setUploadedMentionDocs((prev) => {
+ const next = { ...prev };
+ for (const item of statusResponse.items) {
+ next[item.id] = {
+ id: item.id,
+ title: item.title,
+ document_type: item.document_type,
+ state: item.status.state,
+ reason: item.status.reason,
+ };
+ }
+ return next;
+ });
+
+ handleDocumentsMention(
+ statusResponse.items.map((item) => ({
+ id: item.id,
+ title: item.title,
+ document_type: item.document_type,
+ }))
+ );
+ },
+ [search_space_id, handleDocumentsMention]
+ );
+
+ const handleUploadClick = useCallback(() => {
+ uploadInputRef.current?.click();
+ }, []);
+
+ const handleUploadInputChange = useCallback(
+ async (event: React.ChangeEvent) => {
+ const files = Array.from(event.target.files ?? []);
+ event.target.value = "";
+ if (files.length === 0 || !search_space_id) return;
+
+ setIsUploadingDocs(true);
+ try {
+ const uploadResponse = await documentsApiService.uploadDocument({
+ files,
+ search_space_id: Number(search_space_id),
+ });
+ const uploadedIds = uploadResponse.document_ids ?? [];
+ const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
+ const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
+ if (idsToMention.length === 0) {
+ toast.warning("No documents were created or matched from selected files.");
+ return;
+ }
+
+ await refreshUploadedDocStatuses(idsToMention);
+ if (uploadedIds.length > 0 && duplicateIds.length > 0) {
+ toast.success(
+ `Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
+ );
+ } else if (uploadedIds.length > 0) {
+ toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
+ } else {
+ toast.success(
+ `Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
+ );
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Upload failed";
+ toast.error(`Upload failed: ${message}`);
+ } finally {
+ setIsUploadingDocs(false);
+ }
+ },
+ [search_space_id, refreshUploadedDocStatuses]
+ );
+
+ // Poll status for uploaded mentioned documents until all are ready or removed.
+ useEffect(() => {
+ const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
+ const needsPolling = trackedIds.some((id) => {
+ const state = uploadedMentionDocs[id]?.state;
+ return state === "pending" || state === "processing";
+ });
+ if (!needsPolling) return;
+
+ const interval = setInterval(() => {
+ refreshUploadedDocStatuses(trackedIds).catch((error) => {
+ console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
+ });
+ }, 2500);
+
+ return () => clearInterval(interval);
+ }, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
+
+ // Push upload status directly onto mention chips (instead of separate status rows).
+ useEffect(() => {
+ for (const doc of uploadedMentionedDocs) {
+ const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
+ const statusLabel =
+ state === "ready"
+ ? null
+ : state === "failed"
+ ? "failed"
+ : state === "processing"
+ ? "indexing"
+ : "queued";
+ editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
+ }
+ }, [uploadedMentionedDocs, uploadedMentionDocs]);
+
+ // Prune upload status entries that are no longer mentioned in the composer.
+ useEffect(() => {
+ const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
+ setUploadedMentionDocs((prev) => {
+ let changed = false;
+ const next: Record = {};
+ for (const [key, value] of Object.entries(prev)) {
+ const id = Number(key);
+ if (activeIds.has(id)) {
+ next[id] = value;
+ } else {
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
+ });
+ }, [mentionedDocuments]);
+
return (
{
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
-
-
+
{/* Inline editor with @mention support */}
{
className="min-h-[24px]"
/>
+
{/* Document picker popover (portal to body for proper z-index stacking) */}
{showDocumentPopover &&
@@ -483,33 +670,43 @@ const Composer: FC = () => {
/>,
document.body
)}
-
-
+
uploadedMentionDocs[doc.id]?.state === "failed"
+ )}
+ />
+
);
};
interface ComposerActionProps {
isBlockedByOtherUser?: boolean;
+ onUploadClick: () => void;
+ isUploadingDocs: boolean;
+ blockingUploadedMentionsCount: number;
+ hasFailedUploadedMentions: boolean;
}
-const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => {
- // Check if any attachments are still being processed (running AND progress < 100)
- // When progress is 100, processing is done but waiting for send()
- const hasProcessingAttachments = useAssistantState(({ composer }) =>
- composer.attachments?.some((att) => {
- const status = att.status;
- if (status?.type !== "running") return false;
- const progress = (status as { type: "running"; progress?: number }).progress;
- return progress === undefined || progress < 100;
- })
- );
+const ComposerAction: FC = ({
+ isBlockedByOtherUser = false,
+ onUploadClick,
+ isUploadingDocs,
+ blockingUploadedMentionsCount,
+ hasFailedUploadedMentions,
+}) => {
+ const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
- // Check if composer text is empty
- const isComposerEmpty = useAssistantState(({ composer }) => {
+ // Check if composer text is empty (chips are represented in mentionedDocuments atom)
+ const isComposerTextEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || "";
return text.length === 0;
});
+ const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
// Check if a model is configured
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
@@ -530,25 +727,47 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false
}, [preferences, globalConfigs, userConfigs]);
const isSendDisabled =
- hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
+ isComposerEmpty ||
+ !hasModelConfigured ||
+ isBlockedByOtherUser ||
+ isUploadingDocs ||
+ blockingUploadedMentionsCount > 0;
return (
-
+
+ {isUploadingDocs ? (
+
+ ) : (
+
+ )}
+
- {/* Show processing indicator when attachments are being processed */}
- {hasProcessingAttachments && (
+ {blockingUploadedMentionsCount > 0 && (
-
- Processing...
+ {hasFailedUploadedMentions ? : }
+
+ {hasFailedUploadedMentions
+ ? "Remove or retry failed uploads"
+ : "Waiting for uploaded files to finish indexing"}
+
)}
{/* Show warning when no model is configured */}
- {!hasModelConfigured && !hasProcessingAttachments && (
+ {!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
Select a model
@@ -561,13 +780,17 @@ const ComposerAction: FC
= ({ isBlockedByOtherUser = false
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
- : !hasModelConfigured
- ? "Please select a model from the header to start chatting"
- : hasProcessingAttachments
- ? "Wait for attachments to process"
- : isComposerEmpty
- ? "Enter a message to send"
- : "Send message"
+ : hasFailedUploadedMentions
+ ? "Remove or retry failed uploads before sending"
+ : blockingUploadedMentionsCount > 0
+ ? "Waiting for uploaded files to finish indexing"
+ : isUploadingDocs
+ ? "Uploading documents..."
+ : !hasModelConfigured
+ ? "Please select a model from the header to start chatting"
+ : isComposerEmpty
+ ? "Enter a message to send"
+ : "Send message"
}
side="bottom"
type="submit"
diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx
index e70806d44..7ba5b9462 100644
--- a/surfsense_web/components/assistant-ui/user-message.tsx
+++ b/surfsense_web/components/assistant-ui/user-message.tsx
@@ -3,7 +3,6 @@ import { useAtomValue } from "jotai";
import { FileText, PencilIcon } from "lucide-react";
import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
-import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
interface AuthorMetadata {
@@ -48,9 +47,6 @@ export const UserMessage: FC = () => {
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const metadata = useAssistantState(({ message }) => message?.metadata);
const author = metadata?.custom?.author as AuthorMetadata | undefined;
- const hasAttachments = useAssistantState(
- ({ message }) => message?.attachments && message.attachments.length > 0
- );
return (
{
>
- {/* Display attachments and mentioned documents */}
- {(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
+ {/* Display mentioned documents */}
+ {mentionedDocs && mentionedDocs.length > 0 && (
- {/* Attachments (images show as thumbnails, documents as chips) */}
-
{/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => (
(
export function HeroSection() {
const containerRef = useRef(null);
const parentRef = useRef(null);
- const heroVariant = useFeatureFlagVariantKey("notebooklm_flag");
- const isNotebookLMVariant = heroVariant === "notebooklm";
+ const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
+ const isNotebookLMVariant = heroVariant === "superpowers";
return (
- NotebookLM for Teams
+ NotebookLM with Superpowers
) : (
- <>
- The AI Workspace{" "}
-
-
- Built for Teams
-
+
+
+ NotebookLM for Teams
- >
+
)}
{/* // TODO:aCTUAL DESCRITION */}
- Connect any LLM to your internal knowledge sources and chat with it in real time alongside
- your team.
+ Connect any AI to your documents and knowledge sources.
+
+
+ Then chat with it in real-time, even alongside your team.
diff --git a/surfsense_web/components/homepage/integrations.tsx b/surfsense_web/components/homepage/integrations.tsx
index 53aaf624a..662387de5 100644
--- a/surfsense_web/components/homepage/integrations.tsx
+++ b/surfsense_web/components/homepage/integrations.tsx
@@ -1,5 +1,7 @@
"use client";
-import React, { useEffect, useState } from "react";
+
+import type React from "react";
+import Image from "next/image";
interface Integration {
name: string;
@@ -8,181 +10,210 @@ interface Integration {
const INTEGRATIONS: Integration[] = [
// Search
- { name: "Tavily", icon: "https://www.tavily.com/images/logo.svg" },
- {
- name: "LinkUp",
- icon: "https://framerusercontent.com/images/7zeIm6t3f1HaSltkw8upEvsD80.png?scale-down-to=512",
- },
- { name: "Elasticsearch", icon: "https://cdn.simpleicons.org/elastic/00A9E5" },
+ { name: "Tavily", icon: "/connectors/tavily.svg" },
+ { name: "Elasticsearch", icon: "/connectors/elasticsearch.svg" },
+ { name: "Baidu Search", icon: "/connectors/baidu-search.svg" },
+ { name: "SearXNG", icon: "/connectors/searxng.svg" },
// Communication
- {
- name: "Slack",
- icon: "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
- },
- { name: "Discord", icon: "https://cdn.simpleicons.org/discord/5865F2" },
- { name: "Gmail", icon: "https://cdn.simpleicons.org/gmail/EA4335" },
+ { name: "Slack", icon: "/connectors/slack.svg" },
+ { name: "Discord", icon: "/connectors/discord.svg" },
+ { name: "Gmail", icon: "/connectors/google-gmail.svg" },
+ { name: "Microsoft Teams", icon: "/connectors/microsoft-teams.svg" },
// Project Management
- { name: "Linear", icon: "https://cdn.simpleicons.org/linear/5E6AD2" },
- { name: "Jira", icon: "https://cdn.simpleicons.org/jira/0052CC" },
- { name: "ClickUp", icon: "https://cdn.simpleicons.org/clickup/7B68EE" },
- { name: "Airtable", icon: "https://cdn.simpleicons.org/airtable/18BFFF" },
+ { name: "Linear", icon: "/connectors/linear.svg" },
+ { name: "Jira", icon: "/connectors/jira.svg" },
+ { name: "ClickUp", icon: "/connectors/clickup.svg" },
+ { name: "Airtable", icon: "/connectors/airtable.svg" },
// Documentation & Knowledge
- { name: "Confluence", icon: "https://cdn.simpleicons.org/confluence/172B4D" },
- { name: "Notion", icon: "https://cdn.simpleicons.org/notion/000000/ffffff" },
- { name: "Web Pages", icon: "https://cdn.jsdelivr.net/npm/lucide-static@0.294.0/icons/globe.svg" },
+ { name: "Confluence", icon: "/connectors/confluence.svg" },
+ { name: "Notion", icon: "/connectors/notion.svg" },
+ { name: "BookStack", icon: "/connectors/bookstack.svg" },
+ { name: "Obsidian", icon: "/connectors/obsidian.svg" },
// Cloud Storage
- { name: "Google Drive", icon: "https://cdn.simpleicons.org/googledrive/4285F4" },
- { name: "Dropbox", icon: "https://cdn.simpleicons.org/dropbox/0061FF" },
- {
- name: "Amazon S3",
- icon: "https://upload.wikimedia.org/wikipedia/commons/b/bc/Amazon-S3-Logo.svg",
- },
+ { name: "Google Drive", icon: "/connectors/google-drive.svg" },
// Development
- { name: "GitHub", icon: "https://cdn.simpleicons.org/github/181717/ffffff" },
+ { name: "GitHub", icon: "/connectors/github.svg" },
// Productivity
- { name: "Google Calendar", icon: "https://cdn.simpleicons.org/googlecalendar/4285F4" },
- { name: "Luma", icon: "https://images.lumacdn.com/social-images/default-social-202407.png" },
+ { name: "Google Calendar", icon: "/connectors/google-calendar.svg" },
+ { name: "Luma", icon: "/connectors/luma.svg" },
// Media
- { name: "YouTube", icon: "https://cdn.simpleicons.org/youtube/FF0000" },
+ { name: "YouTube", icon: "/connectors/youtube.svg" },
+
+ // Search
+ { name: "Linkup", icon: "/connectors/linkup.svg" },
+
+ // Meetings
+ { name: "Circleback", icon: "/connectors/circleback.svg" },
+
+ // AI
+ { name: "MCP", icon: "/connectors/modelcontextprotocol.svg" },
];
-function SemiCircleOrbit({ radius, centerX, centerY, count, iconSize, startIndex }: any) {
+// 5 vertical columns — 23 icons spread across categories
+const COLUMNS: number[][] = [
+ [2, 5, 10, 0, 21, 11],
+ [1, 7, 20, 17],
+ [13, 6, 23, 4, 16],
+ [12, 8, 15, 18],
+ [3, 9, 14, 22, 19],
+];
+
+// Different scroll speeds per column for organic feel (seconds)
+const SCROLL_DURATIONS = [26, 32, 22, 30, 28];
+
+function IntegrationCard({ integration }: { integration: Integration }) {
return (
- <>
- {/* Semi-circle glow background */}
-
-
+
+
+
+ );
+}
+
+function ScrollingColumn({
+ cards,
+ scrollUp,
+ duration,
+ colIndex,
+ isEdge,
+ isEdgeAdjacent,
+}: {
+ cards: number[];
+ scrollUp: boolean;
+ duration: number;
+ colIndex: number;
+ isEdge: boolean;
+ isEdgeAdjacent: boolean;
+}) {
+ // Edge columns get a heavy vertical mask; edge-adjacent columns get a lighter one to smooth the transition
+ const columnMask = isEdge
+ ? {
+ maskImage:
+ "linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
+ WebkitMaskImage:
+ "linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
+ }
+ : isEdgeAdjacent
+ ? {
+ maskImage:
+ "linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
+ WebkitMaskImage:
+ "linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
+ }
+ : {};
+
+ const cardSet = cards.map((integrationIndex, i) => (
+
+ ));
+
+ return (
+
+ {/* Outer div has NO gap — each inner copy uses pb matching the gap so both halves are identical in height → seamless -50% loop */}
+
+
+ {cardSet}
+
+
+ {cardSet}
+
-
- {/* Orbit icons */}
- {Array.from({ length: count }).map((_, index) => {
- const actualIndex = startIndex + index;
- // Skip if we've run out of integrations
- if (actualIndex >= INTEGRATIONS.length) return null;
-
- const angle = (index / (count - 1)) * 180;
- const x = radius * Math.cos((angle * Math.PI) / 180);
- const y = radius * Math.sin((angle * Math.PI) / 180);
- const integration = INTEGRATIONS[actualIndex];
-
- // Tooltip positioning — above or below based on angle
- const tooltipAbove = angle > 90;
-
- return (
-
-
-
- {/* Tooltip */}
-
-
- );
- })}
- >
+
);
}
export default function ExternalIntegrations() {
- const [size, setSize] = useState({ width: 0, height: 0 });
-
- useEffect(() => {
- const updateSize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
- updateSize();
- window.addEventListener("resize", updateSize);
- return () => window.removeEventListener("resize", updateSize);
- }, []);
-
- const baseWidth = Math.min(size.width * 0.8, 700);
- const centerX = baseWidth / 2;
- const centerY = baseWidth * 0.5;
-
- const iconSize =
- size.width < 480
- ? Math.max(24, baseWidth * 0.05)
- : size.width < 768
- ? Math.max(28, baseWidth * 0.06)
- : Math.max(32, baseWidth * 0.07);
-
return (
-
-
-
Integrations
-
- Integrate with your team's most important tools
-
+
+ {/* Heading */}
+
+
+ Integrate with your
+
+ team's most important tools
+
+
-
-
-
-
+ {/* Scrolling columns container — masked at edges so the page background shows through seamlessly */}
+
+ {/* 5 scrolling columns */}
+
+ {COLUMNS.map((column, colIndex) => (
+
+ ))}
diff --git a/surfsense_web/components/json-metadata-viewer.tsx b/surfsense_web/components/json-metadata-viewer.tsx
index faab000ad..cc87a75c5 100644
--- a/surfsense_web/components/json-metadata-viewer.tsx
+++ b/surfsense_web/components/json-metadata-viewer.tsx
@@ -1,4 +1,4 @@
-import { FileJson, Loader2 } from "lucide-react";
+import { FileJson } from "lucide-react";
import React from "react";
import { defaultStyles, JsonView } from "react-json-view-lite";
import { Button } from "@/components/ui/button";
@@ -9,6 +9,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
import "react-json-view-lite/dist/index.css";
interface JsonMetadataViewerProps {
@@ -58,7 +59,7 @@ export function JsonMetadataViewer({
{loading ? (
-
+
) : (
diff --git a/surfsense_web/components/layout/hooks/SidebarContext.tsx b/surfsense_web/components/layout/hooks/SidebarContext.tsx
index 35f76929d..bfb5b5aeb 100644
--- a/surfsense_web/components/layout/hooks/SidebarContext.tsx
+++ b/surfsense_web/components/layout/hooks/SidebarContext.tsx
@@ -6,6 +6,7 @@ interface SidebarContextValue {
isCollapsed: boolean;
setIsCollapsed: (collapsed: boolean) => void;
toggleCollapsed: () => void;
+ sidebarWidth: number;
}
const SidebarContext = createContext
(null);
diff --git a/surfsense_web/components/layout/hooks/useSidebarResize.ts b/surfsense_web/components/layout/hooks/useSidebarResize.ts
new file mode 100644
index 000000000..887c86dce
--- /dev/null
+++ b/surfsense_web/components/layout/hooks/useSidebarResize.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
+const SIDEBAR_WIDTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
+
+export const SIDEBAR_MIN_WIDTH = 240;
+export const SIDEBAR_MAX_WIDTH = 480;
+
+interface UseSidebarResizeReturn {
+ sidebarWidth: number;
+ handleMouseDown: (e: React.MouseEvent) => void;
+ isDragging: boolean;
+}
+
+export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
+ const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const startXRef = useRef(0);
+ const startWidthRef = useRef(defaultWidth);
+
+ // Initialize from cookie on mount
+ useEffect(() => {
+ try {
+ const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
+ if (match) {
+ const parsed = Number(match[1]);
+ if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
+ setSidebarWidth(parsed);
+ }
+ }
+ } catch {
+ // Ignore cookie read errors
+ }
+ }, []);
+
+ // Persist width to cookie
+ const persistWidth = useCallback((width: number) => {
+ try {
+ document.cookie = `${SIDEBAR_WIDTH_COOKIE_NAME}=${width}; path=/; max-age=${SIDEBAR_WIDTH_COOKIE_MAX_AGE}`;
+ } catch {
+ // Ignore cookie write errors
+ }
+ }, []);
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ startXRef.current = e.clientX;
+ startWidthRef.current = sidebarWidth;
+ setIsDragging(true);
+
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ },
+ [sidebarWidth]
+ );
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ const delta = e.clientX - startXRef.current;
+ const newWidth = Math.min(
+ SIDEBAR_MAX_WIDTH,
+ Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
+ );
+ setSidebarWidth(newWidth);
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+
+ // Persist the final width
+ setSidebarWidth((currentWidth) => {
+ persistWidth(currentWidth);
+ return currentWidth;
+ });
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+ }, [isDragging, persistWidth]);
+
+ return {
+ sidebarWidth,
+ handleMouseDown,
+ isDragging,
+ };
+}
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index ead017a3e..ed96b84ca 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -25,16 +25,14 @@ import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
-import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { logout } from "@/lib/auth-utils";
+import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
-import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
-import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
@@ -390,7 +388,13 @@ export function LayoutDataProvider({
(item: NavItem) => {
// Handle inbox specially - toggle sidebar instead of navigating
if (item.url === "#inbox") {
- setIsInboxSidebarOpen((prev) => !prev);
+ setIsInboxSidebarOpen((prev) => {
+ if (!prev) {
+ setIsAllSharedChatsSidebarOpen(false);
+ setIsAllPrivateChatsSidebarOpen(false);
+ }
+ return !prev;
+ });
return;
}
router.push(item.url);
@@ -490,10 +494,14 @@ export function LayoutDataProvider({
const handleViewAllSharedChats = useCallback(() => {
setIsAllSharedChatsSidebarOpen(true);
+ setIsAllPrivateChatsSidebarOpen(false);
+ setIsInboxSidebarOpen(false);
}, []);
const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true);
+ setIsAllSharedChatsSidebarOpen(false);
+ setIsInboxSidebarOpen(false);
}, []);
// Delete handlers
@@ -614,6 +622,16 @@ export function LayoutDataProvider({
isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked,
}}
+ allSharedChatsPanel={{
+ open: isAllSharedChatsSidebarOpen,
+ onOpenChange: setIsAllSharedChatsSidebarOpen,
+ searchSpaceId,
+ }}
+ allPrivateChatsPanel={{
+ open: isAllPrivateChatsSidebarOpen,
+ onOpenChange: setIsAllPrivateChatsSidebarOpen,
+ searchSpaceId,
+ }}
>
{children}
@@ -796,20 +814,6 @@ export function LayoutDataProvider({
- {/* All Shared Chats Sidebar */}
-
-
- {/* All Private Chats Sidebar */}
-
-
{/* Create Search Space Dialog */}
void;
+ searchSpaceId: string;
+ };
+ allPrivateChatsPanel?: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ searchSpaceId: string;
+ };
}
export function LayoutShell({
@@ -112,15 +131,22 @@ export function LayoutShell({
className,
inbox,
isLoadingChats = false,
+ allSharedChatsPanel,
+ allPrivateChatsPanel,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
+ const {
+ sidebarWidth,
+ handleMouseDown: onResizeMouseDown,
+ isDragging: isResizing,
+ } = useSidebarResize();
// Memoize context value to prevent unnecessary re-renders
const sidebarContextValue = useMemo(
- () => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
- [isCollapsed, setIsCollapsed, toggleCollapsed]
+ () => ({ isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth }),
+ [isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth]
);
// Mobile layout
@@ -236,6 +262,9 @@ export function LayoutShell({
setTheme={setTheme}
className="hidden md:flex border-r shrink-0"
isLoadingChats={isLoadingChats}
+ sidebarWidth={sidebarWidth}
+ onResizeMouseDown={onResizeMouseDown}
+ isResizing={isResizing}
/>
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
@@ -275,6 +304,24 @@ export function LayoutShell({
onDockedChange={inbox.onDockedChange}
/>
)}
+
+ {/* All Shared Chats - slide-out panel */}
+ {allSharedChatsPanel && (
+
+ )}
+
+ {/* All Private Chats - slide-out panel */}
+ {allPrivateChatsPanel && (
+
+ )}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index 1d4f590bd..3dd77ea1d 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -12,11 +12,9 @@ import {
User,
X,
} from "lucide-react";
-import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -40,6 +38,7 @@ import {
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
+import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AllPrivateChatsSidebarProps {
open: boolean;
@@ -69,16 +68,11 @@ export function AllPrivateChatsSidebar({
const [archivingThreadId, setArchivingThreadId] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
- const [mounted, setMounted] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState(null);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearchQuery.trim();
- useEffect(() => {
- setMounted(true);
- }, []);
-
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
@@ -89,17 +83,6 @@ export function AllPrivateChatsSidebar({
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
- useEffect(() => {
- if (open) {
- document.body.style.overflow = "hidden";
- } else {
- document.body.style.overflow = "";
- }
- return () => {
- document.body.style.overflow = "";
- };
- }, [open]);
-
const {
data: threadsData,
error: threadsError,
@@ -214,248 +197,221 @@ export function AllPrivateChatsSidebar({
const activeCount = activeChats.length;
const archivedCount = archivedChats.length;
- if (!mounted) return null;
+ return (
+
+
+
+
+
{t("chats") || "Private Chats"}
+
- return createPortal(
-
- {open && (
- <>
- onOpenChange(false)}
- aria-hidden="true"
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 pr-8 h-9"
/>
+ {searchQuery && (
+
+
+ {t("clear_search") || "Clear search"}
+
+ )}
+
+
-
-
-
-
-
{t("chats") || "Private Chats"}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-9 pr-8 h-9"
- />
- {searchQuery && (
-
-
- {t("clear_search") || "Clear search"}
-
- )}
-
-
-
- {!isSearchMode && (
- setShowArchived(value === "archived")}
- className="shrink-0 mx-4"
- >
-
-
-
-
- Active
-
- {activeCount}
-
-
-
-
-
-
- Archived
-
- {archivedCount}
-
-
-
-
-
- )}
-
-
- {isLoading ? (
-
- {[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
-
-
-
-
- ))}
-
- ) : error ? (
-
- {t("error_loading_chats") || "Error loading chats"}
-
- ) : threads.length > 0 ? (
-
- {threads.map((thread) => {
- const isDeleting = deletingThreadId === thread.id;
- const isArchiving = archivingThreadId === thread.id;
- const isBusy = isDeleting || isArchiving;
- const isActive = currentChatId === thread.id;
-
- return (
-
- {isMobile ? (
-
handleThreadClick(thread.id)}
- disabled={isBusy}
- className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
- >
-
- {thread.title || "New Chat"}
-
- ) : (
-
-
- handleThreadClick(thread.id)}
- disabled={isBusy}
- className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
- >
-
- {thread.title || "New Chat"}
-
-
-
-
- {t("updated") || "Updated"}:{" "}
- {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
-
-
-
- )}
-
-
setOpenDropdownId(isOpen ? thread.id : null)}
- >
-
-
- {isDeleting ? (
-
- ) : (
-
- )}
- {t("more_options") || "More options"}
-
-
-
- handleToggleArchive(thread.id, thread.archived)}
- disabled={isArchiving}
- >
- {thread.archived ? (
- <>
-
- {t("unarchive") || "Restore"}
- >
- ) : (
- <>
-
- {t("archive") || "Archive"}
- >
- )}
-
-
- handleDeleteThread(thread.id)}
- className="text-destructive focus:text-destructive"
- >
-
- {t("delete") || "Delete"}
-
-
-
-
- );
- })}
-
- ) : isSearchMode ? (
-
-
-
- {t("no_chats_found") || "No chats found"}
-
-
- {t("try_different_search") || "Try a different search term"}
-
-
- ) : (
-
-
-
- {showArchived
- ? t("no_archived_chats") || "No archived chats"
- : t("no_chats") || "No private chats"}
-
- {!showArchived && (
-
- {t("start_new_chat_hint") || "Start a new chat from the chat page"}
-
- )}
-
- )}
-
-
- >
+ {!isSearchMode && (
+ setShowArchived(value === "archived")}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ Active
+
+ {activeCount}
+
+
+
+
+
+
+ Archived
+
+ {archivedCount}
+
+
+
+
+
)}
- ,
- document.body
+
+
+ {isLoading ? (
+
+ {[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ {t("error_loading_chats") || "Error loading chats"}
+
+ ) : threads.length > 0 ? (
+
+ {threads.map((thread) => {
+ const isDeleting = deletingThreadId === thread.id;
+ const isArchiving = archivingThreadId === thread.id;
+ const isBusy = isDeleting || isArchiving;
+ const isActive = currentChatId === thread.id;
+
+ return (
+
+ {isMobile ? (
+
handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+ ) : (
+
+
+ handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+
+
+
+ {t("updated") || "Updated"}:{" "}
+ {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
+
+
+
+ )}
+
+
setOpenDropdownId(isOpen ? thread.id : null)}
+ >
+
+
+ {isDeleting ? (
+
+ ) : (
+
+ )}
+ {t("more_options") || "More options"}
+
+
+
+ handleToggleArchive(thread.id, thread.archived)}
+ disabled={isArchiving}
+ >
+ {thread.archived ? (
+ <>
+
+ {t("unarchive") || "Restore"}
+ >
+ ) : (
+ <>
+
+ {t("archive") || "Archive"}
+ >
+ )}
+
+
+ handleDeleteThread(thread.id)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {t("delete") || "Delete"}
+
+
+
+
+ );
+ })}
+
+ ) : isSearchMode ? (
+
+
+
+ {t("no_chats_found") || "No chats found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
+
+ ) : (
+
+
+
+ {showArchived
+ ? t("no_archived_chats") || "No archived chats"
+ : t("no_chats") || "No private chats"}
+
+ {!showArchived && (
+
+ {t("start_new_chat_hint") || "Start a new chat from the chat page"}
+
+ )}
+
+ )}
+
+
);
}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index db7ca73b2..fae9fb05e 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -12,11 +12,9 @@ import {
Users,
X,
} from "lucide-react";
-import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -40,6 +38,7 @@ import {
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
+import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AllSharedChatsSidebarProps {
open: boolean;
@@ -69,16 +68,11 @@ export function AllSharedChatsSidebar({
const [archivingThreadId, setArchivingThreadId] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
- const [mounted, setMounted] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState(null);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearchQuery.trim();
- useEffect(() => {
- setMounted(true);
- }, []);
-
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
@@ -89,17 +83,6 @@ export function AllSharedChatsSidebar({
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
- useEffect(() => {
- if (open) {
- document.body.style.overflow = "hidden";
- } else {
- document.body.style.overflow = "";
- }
- return () => {
- document.body.style.overflow = "";
- };
- }, [open]);
-
const {
data: threadsData,
error: threadsError,
@@ -214,248 +197,221 @@ export function AllSharedChatsSidebar({
const activeCount = activeChats.length;
const archivedCount = archivedChats.length;
- if (!mounted) return null;
+ return (
+
+
+
+
+
{t("shared_chats") || "Shared Chats"}
+
- return createPortal(
-
- {open && (
- <>
- onOpenChange(false)}
- aria-hidden="true"
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 pr-8 h-9"
/>
+ {searchQuery && (
+
+
+ {t("clear_search") || "Clear search"}
+
+ )}
+
+
-
-
-
-
-
{t("shared_chats") || "Shared Chats"}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-9 pr-8 h-9"
- />
- {searchQuery && (
-
-
- {t("clear_search") || "Clear search"}
-
- )}
-
-
-
- {!isSearchMode && (
- setShowArchived(value === "archived")}
- className="shrink-0 mx-4"
- >
-
-
-
-
- Active
-
- {activeCount}
-
-
-
-
-
-
- Archived
-
- {archivedCount}
-
-
-
-
-
- )}
-
-
- {isLoading ? (
-
- {[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
-
-
-
-
- ))}
-
- ) : error ? (
-
- {t("error_loading_chats") || "Error loading chats"}
-
- ) : threads.length > 0 ? (
-
- {threads.map((thread) => {
- const isDeleting = deletingThreadId === thread.id;
- const isArchiving = archivingThreadId === thread.id;
- const isBusy = isDeleting || isArchiving;
- const isActive = currentChatId === thread.id;
-
- return (
-
- {isMobile ? (
-
handleThreadClick(thread.id)}
- disabled={isBusy}
- className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
- >
-
- {thread.title || "New Chat"}
-
- ) : (
-
-
- handleThreadClick(thread.id)}
- disabled={isBusy}
- className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
- >
-
- {thread.title || "New Chat"}
-
-
-
-
- {t("updated") || "Updated"}:{" "}
- {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
-
-
-
- )}
-
-
setOpenDropdownId(isOpen ? thread.id : null)}
- >
-
-
- {isDeleting ? (
-
- ) : (
-
- )}
- {t("more_options") || "More options"}
-
-
-
- handleToggleArchive(thread.id, thread.archived)}
- disabled={isArchiving}
- >
- {thread.archived ? (
- <>
-
- {t("unarchive") || "Restore"}
- >
- ) : (
- <>
-
- {t("archive") || "Archive"}
- >
- )}
-
-
- handleDeleteThread(thread.id)}
- className="text-destructive focus:text-destructive"
- >
-
- {t("delete") || "Delete"}
-
-
-
-
- );
- })}
-
- ) : isSearchMode ? (
-
-
-
- {t("no_chats_found") || "No chats found"}
-
-
- {t("try_different_search") || "Try a different search term"}
-
-
- ) : (
-
-
-
- {showArchived
- ? t("no_archived_chats") || "No archived chats"
- : t("no_shared_chats") || "No shared chats"}
-
- {!showArchived && (
-
- Share a chat to collaborate with your team
-
- )}
-
- )}
-
-
- >
+ {!isSearchMode && (
+ setShowArchived(value === "archived")}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ Active
+
+ {activeCount}
+
+
+
+
+
+
+ Archived
+
+ {archivedCount}
+
+
+
+
+
)}
- ,
- document.body
+
+
+ {isLoading ? (
+
+ {[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ {t("error_loading_chats") || "Error loading chats"}
+
+ ) : threads.length > 0 ? (
+
+ {threads.map((thread) => {
+ const isDeleting = deletingThreadId === thread.id;
+ const isArchiving = archivingThreadId === thread.id;
+ const isBusy = isDeleting || isArchiving;
+ const isActive = currentChatId === thread.id;
+
+ return (
+
+ {isMobile ? (
+
handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+ ) : (
+
+
+ handleThreadClick(thread.id)}
+ disabled={isBusy}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
+ >
+
+ {thread.title || "New Chat"}
+
+
+
+
+ {t("updated") || "Updated"}:{" "}
+ {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
+
+
+
+ )}
+
+
setOpenDropdownId(isOpen ? thread.id : null)}
+ >
+
+
+ {isDeleting ? (
+
+ ) : (
+
+ )}
+ {t("more_options") || "More options"}
+
+
+
+ handleToggleArchive(thread.id, thread.archived)}
+ disabled={isArchiving}
+ >
+ {thread.archived ? (
+ <>
+
+ {t("unarchive") || "Restore"}
+ >
+ ) : (
+ <>
+
+ {t("archive") || "Archive"}
+ >
+ )}
+
+
+ handleDeleteThread(thread.id)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {t("delete") || "Delete"}
+
+
+
+
+ );
+ })}
+
+ ) : isSearchMode ? (
+
+
+
+ {t("no_chats_found") || "No chats found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
+
+ ) : (
+
+
+
+ {showArchived
+ ? t("no_archived_chats") || "No archived chats"
+ : t("no_shared_chats") || "No shared chats"}
+
+ {!showArchived && (
+
+ Share a chat to collaborate with your team
+
+ )}
+
+ )}
+
+
);
}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index b6caed330..ce64adae9 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -19,7 +19,6 @@ import {
Search,
X,
} from "lucide-react";
-import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -53,17 +52,13 @@ import {
isNewMentionMetadata,
isPageLimitExceededMetadata,
} from "@/contracts/types/inbox.types";
-import type { InboxItem } from "@/hooks/use-inbox";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
-import { useSidebarContextSafe } from "../../hooks";
-
-// Sidebar width constants
-const SIDEBAR_COLLAPSED_WIDTH = 60;
-const SIDEBAR_EXPANDED_WIDTH = 240;
+import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
/**
* Get initials from name or email for avatar fallback
@@ -561,13 +556,6 @@ export function InboxSidebar({
};
};
- // Get sidebar collapsed state from context (provided by LayoutShell)
- const sidebarContext = useSidebarContextSafe();
- const isCollapsed = sidebarContext?.isCollapsed ?? false;
-
- // Calculate the left position for the inbox panel (relative to sidebar)
- const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
-
if (!mounted) return null;
// Shared content component for both docked and floating modes
@@ -1126,49 +1114,8 @@ export function InboxSidebar({
// FLOATING MODE: Render with animation and click-away layer
return (
-
- {open && (
- <>
- {/* Click-away layer - only covers the content area, not the sidebar */}
- onOpenChange(false)}
- aria-hidden="true"
- />
-
- {/* Clip container - positioned at sidebar edge with overflow hidden */}
-
-
- {inboxContent}
-
-
- >
- )}
-
+
+ {inboxContent}
+
);
}
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index 883fa5890..aee98a290 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
+import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
@@ -51,6 +52,9 @@ interface SidebarProps {
className?: string;
isLoadingChats?: boolean;
disableTooltips?: boolean;
+ sidebarWidth?: number;
+ onResizeMouseDown?: (e: React.MouseEvent) => void;
+ isResizing?: boolean;
}
export function Sidebar({
@@ -80,17 +84,29 @@ export function Sidebar({
className,
isLoadingChats = false,
disableTooltips = false,
+ sidebarWidth = SIDEBAR_MIN_WIDTH,
+ onResizeMouseDown,
+ isResizing = false,
}: SidebarProps) {
const t = useTranslations("sidebar");
return (
+ {/* Resize handle on right border */}
+ {!isCollapsed && onResizeMouseDown && (
+
+ )}
{/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? (
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx
new file mode 100644
index 000000000..beb1c81c0
--- /dev/null
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import { cn } from "@/lib/utils";
+import { useSidebarContextSafe } from "../../hooks";
+
+const SIDEBAR_COLLAPSED_WIDTH = 60;
+
+interface SidebarSlideOutPanelProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ ariaLabel: string;
+ width?: number;
+ children: React.ReactNode;
+}
+
+/**
+ * Reusable slide-out panel that appears from the right edge of the sidebar.
+ * Used by InboxSidebar (floating mode), AllSharedChatsSidebar, and AllPrivateChatsSidebar.
+ *
+ * Must be rendered inside a positioned container (the LayoutShell's relative flex container)
+ * and within the SidebarProvider context.
+ */
+export function SidebarSlideOutPanel({
+ open,
+ onOpenChange,
+ ariaLabel,
+ width = 360,
+ children,
+}: SidebarSlideOutPanelProps) {
+ const isMobile = !useMediaQuery("(min-width: 640px)");
+ const sidebarContext = useSidebarContextSafe();
+ const isCollapsed = sidebarContext?.isCollapsed ?? false;
+ const sidebarWidth = isCollapsed
+ ? SIDEBAR_COLLAPSED_WIDTH
+ : (sidebarContext?.sidebarWidth ?? 240);
+
+ return (
+
+ {open && (
+ <>
+ {/* Click-away layer - covers the full container including the sidebar */}
+ onOpenChange(false)}
+ aria-hidden="true"
+ />
+
+ {/* Clip container - positioned at sidebar edge with overflow hidden */}
+
+
+ {children}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
index 997482ed3..ea9b0189c 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
@@ -1,16 +1,6 @@
"use client";
-import {
- Check,
- ChevronUp,
- Languages,
- Laptop,
- Loader2,
- LogOut,
- Moon,
- Settings,
- Sun,
-} from "lucide-react";
+import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
@@ -25,6 +15,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLocaleContext } from "@/contexts/LocaleContext";
import { cn } from "@/lib/utils";
@@ -266,7 +257,7 @@ export function SidebarUserProfile({
{isLoggingOut ? (
-
+
) : (
)}
@@ -388,7 +379,7 @@ export function SidebarUserProfile({
{isLoggingOut ? (
-
+
) : (
)}
diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx
index 2211f3142..79b317ddf 100644
--- a/surfsense_web/components/public-chat/public-chat-footer.tsx
+++ b/surfsense_web/components/public-chat/public-chat-footer.tsx
@@ -1,10 +1,11 @@
"use client";
-import { Copy, Loader2 } from "lucide-react";
+import { Copy } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
import { getBearerToken } from "@/lib/auth-utils";
@@ -61,9 +62,14 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
};
return (
-
-
- {isCloning ? : }
+
+
+ {isCloning ? : }
Copy and continue this chat
diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx
index 08a450d06..a1e6008ff 100644
--- a/surfsense_web/components/public-chat/public-chat-view.tsx
+++ b/surfsense_web/components/public-chat/public-chat-view.tsx
@@ -1,12 +1,12 @@
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
-import { Loader2 } from "lucide-react";
import { Navbar } from "@/components/homepage/navbar";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
+import { Spinner } from "@/components/ui/spinner";
import { usePublicChat } from "@/hooks/use-public-chat";
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
import { PublicChatFooter } from "./public-chat-footer";
@@ -26,7 +26,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
-
+
);
diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx
index 1e1354718..80828f825 100644
--- a/surfsense_web/components/settings/image-model-manager.tsx
+++ b/surfsense_web/components/settings/image-model-manager.tsx
@@ -71,8 +71,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
- IMAGE_GEN_PROVIDERS,
getImageGenModelsByProvider,
+ IMAGE_GEN_PROVIDERS,
} from "@/contracts/enums/image-gen-providers";
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
diff --git a/surfsense_web/components/ui/checkbox.tsx b/surfsense_web/components/ui/checkbox.tsx
index 2b59d1f47..789a6b68b 100644
--- a/surfsense_web/components/ui/checkbox.tsx
+++ b/surfsense_web/components/ui/checkbox.tsx
@@ -1,7 +1,7 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
-import { CheckIcon } from "lucide-react";
+import { CheckIcon, MinusIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
@@ -11,16 +11,17 @@ function Checkbox({ className, ...props }: React.ComponentProps
-
+
+
);
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index 18a872d94..c9375a5ca 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -1,4 +1,4 @@
-import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react";
+import { IconUsersGroup } from "@tabler/icons-react";
import {
BookOpen,
File,
@@ -15,11 +15,16 @@ import { EnumConnectorName } from "./connector";
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
const iconProps = { className: className || "h-4 w-4" };
- const imgProps = { className: className || "h-5 w-5", width: 20, height: 20 };
+ const imgProps = {
+ className: `${className || "h-5 w-5"} select-none pointer-events-none`,
+ width: 20,
+ height: 20,
+ draggable: false as const,
+ };
switch (connectorType) {
case EnumConnectorName.LINKUP_API:
- return ;
+ return ;
case EnumConnectorName.LINEAR_CONNECTOR:
return ;
case EnumConnectorName.GITHUB_CONNECTOR:
@@ -63,7 +68,7 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
case EnumConnectorName.YOUTUBE_CONNECTOR:
return ;
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
- return ;
+ return ;
case EnumConnectorName.MCP_CONNECTOR:
return ;
case EnumConnectorName.OBSIDIAN_CONNECTOR:
diff --git a/surfsense_web/contracts/enums/llm-models.ts b/surfsense_web/contracts/enums/llm-models.ts
index 5ff15c3df..467522faa 100644
--- a/surfsense_web/contracts/enums/llm-models.ts
+++ b/surfsense_web/contracts/enums/llm-models.ts
@@ -1477,6 +1477,78 @@ export const LLM_MODELS: LLMModel[] = [
provider: "DATABRICKS",
contextWindow: "128K",
},
+
+ // GitHub Models
+ {
+ value: "openai/gpt-5",
+ label: "GitHub GPT-5",
+ provider: "GITHUB_MODELS",
+ },
+ {
+ value: "openai/gpt-4.1",
+ label: "GitHub GPT-4.1",
+ provider: "GITHUB_MODELS",
+ contextWindow: "1048K",
+ },
+ {
+ value: "openai/gpt-4o",
+ label: "GitHub GPT-4o",
+ provider: "GITHUB_MODELS",
+ contextWindow: "128K",
+ },
+ {
+ value: "deepseek/DeepSeek-V3-0324",
+ label: "GitHub DeepSeek V3",
+ provider: "GITHUB_MODELS",
+ contextWindow: "64K",
+ },
+ {
+ value: "xai/grok-3",
+ label: "GitHub Grok 3",
+ provider: "GITHUB_MODELS",
+ contextWindow: "131K",
+ },
+ {
+ value: "openai/gpt-5-mini",
+ label: "GitHub GPT-5 Mini",
+ provider: "GITHUB_MODELS",
+ },
+ {
+ value: "openai/gpt-4.1-mini",
+ label: "GitHub GPT-4.1 Mini",
+ provider: "GITHUB_MODELS",
+ contextWindow: "1048K",
+ },
+ {
+ value: "meta/Llama-4-Scout-17B-16E-Instruct",
+ label: "GitHub Llama 4 Scout",
+ provider: "GITHUB_MODELS",
+ contextWindow: "512K",
+ },
+ {
+ value: "openai/gpt-4.1-nano",
+ label: "GitHub GPT-4.1 Nano",
+ provider: "GITHUB_MODELS",
+ contextWindow: "1048K",
+ },
+ {
+ value: "openai/gpt-4o-mini",
+ label: "GitHub GPT-4o Mini",
+ provider: "GITHUB_MODELS",
+ contextWindow: "128K",
+ },
+ {
+ value: "openai/o4-mini",
+ label: "GitHub O4 Mini",
+ provider: "GITHUB_MODELS",
+ contextWindow: "200K",
+ },
+ {
+ value: "deepseek/DeepSeek-R1",
+ label: "GitHub DeepSeek R1",
+ provider: "GITHUB_MODELS",
+ contextWindow: "64K",
+ },
];
// Helper function to get models by provider
diff --git a/surfsense_web/contracts/enums/llm-providers.ts b/surfsense_web/contracts/enums/llm-providers.ts
index 40b7ee2df..7e6542c11 100644
--- a/surfsense_web/contracts/enums/llm-providers.ts
+++ b/surfsense_web/contracts/enums/llm-providers.ts
@@ -174,6 +174,13 @@ export const LLM_PROVIDERS: LLMProvider[] = [
example: "databricks/databricks-meta-llama-3-3-70b-instruct",
description: "Databricks Model Serving",
},
+ {
+ value: "GITHUB_MODELS",
+ label: "GitHub Models",
+ example: "openai/gpt-5, meta/llama-3.1-405b-instruct",
+ description: "AI models from GitHub Marketplace",
+ apiBase: "https://models.github.ai/inference",
+ },
{
value: "CUSTOM",
label: "Custom Provider",
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index b7a2d2cf8..f96ecf85e 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -138,6 +138,37 @@ export const uploadDocumentRequest = z.object({
export const uploadDocumentResponse = z.object({
message: z.literal("Files uploaded for processing"),
+ document_ids: z.array(z.number()),
+ duplicate_document_ids: z.array(z.number()).optional(),
+ total_files: z.number().optional(),
+ pending_files: z.number().optional(),
+ skipped_duplicates: z.number().optional(),
+});
+
+/**
+ * Batch document status
+ */
+export const getDocumentsStatusRequest = z.object({
+ queryParams: z.object({
+ search_space_id: z.number(),
+ document_ids: z.array(z.number()).min(1),
+ }),
+});
+
+export const documentStatus = z.object({
+ state: z.enum(["ready", "pending", "processing", "failed"]),
+ reason: z.string().nullable().optional(),
+});
+
+export const documentStatusItem = z.object({
+ id: z.number(),
+ title: z.string(),
+ document_type: documentTypeEnum,
+ status: documentStatus,
+});
+
+export const getDocumentsStatusResponse = z.object({
+ items: z.array(documentStatusItem),
});
/**
@@ -261,6 +292,10 @@ export type CreateDocumentRequest = z.infer;
export type CreateDocumentResponse = z.infer;
export type UploadDocumentRequest = z.infer;
export type UploadDocumentResponse = z.infer;
+export type GetDocumentsStatusRequest = z.infer;
+export type GetDocumentsStatusResponse = z.infer;
+export type DocumentStatus = z.infer;
+export type DocumentStatusItem = z.infer;
export type SearchDocumentsRequest = z.infer;
export type SearchDocumentsResponse = z.infer;
export type SearchDocumentTitlesRequest = z.infer;
diff --git a/surfsense_web/contracts/types/incentive-tasks.types.ts b/surfsense_web/contracts/types/incentive-tasks.types.ts
index 56a62fa06..c45121c29 100644
--- a/surfsense_web/contracts/types/incentive-tasks.types.ts
+++ b/surfsense_web/contracts/types/incentive-tasks.types.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
/**
* Incentive task type enum - matches backend IncentiveTaskType
*/
-export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR"]);
+export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR", "REDDIT_FOLLOW", "DISCORD_JOIN"]);
/**
* Single incentive task info schema
diff --git a/surfsense_web/contracts/types/new-llm-config.types.ts b/surfsense_web/contracts/types/new-llm-config.types.ts
index 0885fa7f5..b01e2abdf 100644
--- a/surfsense_web/contracts/types/new-llm-config.types.ts
+++ b/surfsense_web/contracts/types/new-llm-config.types.ts
@@ -33,6 +33,7 @@ export const liteLLMProviderEnum = z.enum([
"DATABRICKS",
"COMETAPI",
"HUGGINGFACE",
+ "GITHUB_MODELS",
"CUSTOM",
]);
diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts
index 03d86a253..c9d649c68 100644
--- a/surfsense_web/lib/apis/documents-api.service.ts
+++ b/surfsense_web/lib/apis/documents-api.service.ts
@@ -8,6 +8,7 @@ import {
type GetDocumentByChunkRequest,
type GetDocumentRequest,
type GetDocumentsRequest,
+ type GetDocumentsStatusRequest,
type GetDocumentTypeCountsRequest,
type GetSurfsenseDocsRequest,
getDocumentByChunkRequest,
@@ -16,6 +17,8 @@ import {
getDocumentResponse,
getDocumentsRequest,
getDocumentsResponse,
+ getDocumentsStatusRequest,
+ getDocumentsStatusResponse,
getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse,
@@ -130,6 +133,30 @@ class DocumentsApiService {
});
};
+ /**
+ * Batch document status for async processing tracking
+ */
+ getDocumentsStatus = async (request: GetDocumentsStatusRequest) => {
+ const parsedRequest = getDocumentsStatusRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { search_space_id, document_ids } = parsedRequest.data.queryParams;
+ const params = new URLSearchParams({
+ search_space_id: String(search_space_id),
+ document_ids: document_ids.join(","),
+ });
+
+ return baseApiService.get(
+ `/api/v1/documents/status?${params.toString()}`,
+ getDocumentsStatusResponse
+ );
+ };
+
/**
* Search documents by title
*/
diff --git a/surfsense_web/lib/apis/image-gen-config-api.service.ts b/surfsense_web/lib/apis/image-gen-config-api.service.ts
index 379edfa53..a9d444d21 100644
--- a/surfsense_web/lib/apis/image-gen-config-api.service.ts
+++ b/surfsense_web/lib/apis/image-gen-config-api.service.ts
@@ -2,12 +2,12 @@ import {
type CreateImageGenConfigRequest,
createImageGenConfigRequest,
createImageGenConfigResponse,
+ deleteImageGenConfigResponse,
+ getGlobalImageGenConfigsResponse,
+ getImageGenConfigsResponse,
type UpdateImageGenConfigRequest,
updateImageGenConfigRequest,
updateImageGenConfigResponse,
- deleteImageGenConfigResponse,
- getImageGenConfigsResponse,
- getGlobalImageGenConfigsResponse,
} from "@/contracts/types/new-llm-config.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
diff --git a/surfsense_web/lib/auth-errors.ts b/surfsense_web/lib/auth-errors.ts
index ce50c9c99..d1816cbdd 100644
--- a/surfsense_web/lib/auth-errors.ts
+++ b/surfsense_web/lib/auth-errors.ts
@@ -20,8 +20,8 @@ const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
description: "Your account may be suspended or restricted",
},
"404": {
- title: "Account not found",
- description: "No account exists with this email address",
+ title: "Not found",
+ description: "The requested resource was not found",
},
"409": {
title: "Account conflict",
@@ -31,6 +31,10 @@ const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
title: "Too many attempts",
description: "Please wait before trying again",
},
+ RATE_LIMIT_EXCEEDED: {
+ title: "Too many attempts",
+ description: "You've made too many requests. Please wait a minute and try again.",
+ },
"500": {
title: "Server error",
description: "Something went wrong on our end. Please try again",
@@ -42,8 +46,8 @@ const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
// FastAPI specific errors
LOGIN_BAD_CREDENTIALS: {
- title: "Invalid credentials",
- description: "The email or password you entered is incorrect",
+ title: "Login failed",
+ description: "Invalid email or password. If you don't have an account, please sign up.",
},
LOGIN_USER_NOT_VERIFIED: {
title: "Account not verified",
diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts
index 8c067a4b7..c2a0d58a5 100644
--- a/surfsense_web/lib/auth-utils.ts
+++ b/surfsense_web/lib/auth-utils.ts
@@ -10,28 +10,53 @@ const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
let isRefreshing = false;
let refreshPromise: Promise | null = null;
+/** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */
+const PUBLIC_ROUTE_PREFIXES = [
+ "/login",
+ "/register",
+ "/auth",
+ "/docs",
+ "/public",
+ "/invite",
+ "/contact",
+ "/pricing",
+ "/privacy",
+ "/terms",
+ "/changelog",
+];
+
/**
- * Saves the current path and redirects to login page
- * Call this when a 401 response is received
+ * Returns true if the pathname is a public route where we should not run auth checks
+ * or redirect to login on 401.
+ */
+export function isPublicRoute(pathname: string): boolean {
+ if (pathname === "/" || pathname === "") return true;
+ return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
+}
+
+/**
+ * Clears tokens and optionally redirects to login.
+ * Call this when a 401 response is received.
+ * Only redirects when the current route is protected; on public routes we just clear tokens.
*/
export function handleUnauthorized(): void {
if (typeof window === "undefined") return;
- // Save the current path (including search params and hash) for redirect after login
- const currentPath = window.location.pathname + window.location.search + window.location.hash;
+ const pathname = window.location.pathname;
- // Don't save auth-related paths
- const excludedPaths = ["/auth", "/auth/callback", "/"];
- if (!excludedPaths.includes(window.location.pathname)) {
- localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
- }
-
- // Clear both tokens
+ // Always clear tokens
localStorage.removeItem(BEARER_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
- // Redirect to home page (which has login options)
- window.location.href = "/login";
+ // Only redirect on protected routes; stay on public pages (e.g. /docs)
+ if (!isPublicRoute(pathname)) {
+ const currentPath = pathname + window.location.search + window.location.hash;
+ const excludedPaths = ["/auth", "/auth/callback", "/"];
+ if (!excludedPaths.includes(pathname)) {
+ localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
+ }
+ window.location.href = "/login";
+ }
}
/**
@@ -179,7 +204,6 @@ export function getAuthHeaders(additionalHeaders?: Record): Reco
/**
* Attempts to refresh the access token using the stored refresh token.
* Returns the new access token if successful, null otherwise.
- * Exported for use by API services.
*/
export async function refreshAccessToken(): Promise {
// If already refreshing, wait for that request to complete
diff --git a/surfsense_web/lib/chat/attachment-adapter.ts b/surfsense_web/lib/chat/attachment-adapter.ts
deleted file mode 100644
index f084af411..000000000
--- a/surfsense_web/lib/chat/attachment-adapter.ts
+++ /dev/null
@@ -1,324 +0,0 @@
-/**
- * Attachment adapter for assistant-ui
- *
- * This adapter handles file uploads by:
- * 1. Uploading the file to the backend /attachments/process endpoint
- * 2. The backend extracts markdown content using the configured ETL service
- * 3. The extracted content is stored in the attachment and sent with messages
- */
-
-import type { AttachmentAdapter, CompleteAttachment, PendingAttachment } from "@assistant-ui/react";
-import { getBearerToken } from "@/lib/auth-utils";
-
-/**
- * Supported file types for the attachment adapter
- *
- * - Text/Markdown: .md, .markdown, .txt
- * - Audio (if STT configured): .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm
- * - Documents (depends on ETL service): .pdf, .docx, .doc, .pptx, .xlsx, .html
- * - Images: .jpg, .jpeg, .png, .gif, .webp
- */
-const ACCEPTED_FILE_TYPES = [
- // Text/Markdown (always supported)
- ".md",
- ".markdown",
- ".txt",
- // Audio files
- ".mp3",
- ".mp4",
- ".mpeg",
- ".mpga",
- ".m4a",
- ".wav",
- ".webm",
- // Document files (depends on ETL service)
- ".pdf",
- ".docx",
- ".doc",
- ".pptx",
- ".xlsx",
- ".html",
- // Image files
- ".jpg",
- ".jpeg",
- ".png",
- ".gif",
- ".webp",
-].join(",");
-
-/**
- * Response from the attachment processing endpoint
- */
-interface ProcessAttachmentResponse {
- id: string;
- name: string;
- type: "document" | "image" | "file";
- content: string;
- contentLength: number;
-}
-
-/**
- * Extended CompleteAttachment with our custom extractedContent field
- * We store the extracted text in a custom field so we can access it in onNew
- * For images, we also store the data URL so it can be displayed after persistence
- */
-export interface ChatAttachment extends CompleteAttachment {
- extractedContent: string;
- imageDataUrl?: string; // Base64 data URL for images (persists across page reloads)
-}
-
-/**
- * Process a file through the backend ETL service
- */
-async function processAttachment(file: File): Promise {
- const token = getBearerToken();
- if (!token) {
- throw new Error("Not authenticated");
- }
-
- const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
-
- const formData = new FormData();
- formData.append("file", file);
-
- const response = await fetch(`${backendUrl}/api/v1/attachments/process`, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${token}`,
- },
- body: formData,
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error("[processAttachment] Error response:", errorText);
- let errorDetail = "Unknown error";
- try {
- const errorJson = JSON.parse(errorText);
- // FastAPI validation errors return detail as array
- if (Array.isArray(errorJson.detail)) {
- errorDetail = errorJson.detail
- .map((err: { msg?: string; loc?: string[] }) => {
- const field = err.loc?.join(".") || "unknown";
- return `${field}: ${err.msg || "validation error"}`;
- })
- .join("; ");
- } else if (typeof errorJson.detail === "string") {
- errorDetail = errorJson.detail;
- } else {
- errorDetail = JSON.stringify(errorJson);
- }
- } catch {
- errorDetail = errorText || `HTTP ${response.status}`;
- }
- throw new Error(errorDetail);
- }
-
- return response.json();
-}
-
-// Store processed results for the send() method
-const processedAttachments = new Map();
-
-// Store image data URLs for attachments (so they persist after File objects are lost)
-const imageDataUrls = new Map();
-
-/**
- * Convert a File to a data URL (base64) for images
- */
-async function fileToDataUrl(file: File): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result as string);
- reader.onerror = reject;
- reader.readAsDataURL(file);
- });
-}
-
-/**
- * Create the attachment adapter for assistant-ui
- *
- * This adapter:
- * 1. Accepts file upload
- * 2. Processes the file through the backend ETL service
- * 3. Returns the attachment with extracted markdown content
- *
- * The content is stored in the attachment and will be sent with the message.
- */
-export function createAttachmentAdapter(): AttachmentAdapter {
- return {
- accept: ACCEPTED_FILE_TYPES,
-
- /**
- * Async generator that yields pending states while processing
- * and returns a pending attachment when done.
- *
- * IMPORTANT: The generator should return status: { type: "running", progress: 100 }
- * NOT status: { type: "complete" }. The "complete" status is set by send().
- * Returning "complete" from the generator will prevent send() from being called!
- *
- * This pattern allows the UI to show a loading indicator
- * while the file is being processed by the backend.
- * The send() method is called to finalize the attachment.
- */
- async *add(input: File | { file: File }): AsyncGenerator {
- // Handle both direct File and { file: File } patterns
- const file = input instanceof File ? input : input.file;
-
- if (!file) {
- console.error("[AttachmentAdapter] No file found in input:", input);
- throw new Error("No file provided");
- }
-
- // Generate a unique ID for this attachment
- const id = crypto.randomUUID();
-
- // Determine attachment type from file
- const attachmentType = file.type.startsWith("image/") ? "image" : "document";
-
- // Yield initial pending state with "running" status (0% progress)
- // This triggers the loading indicator in the UI
- yield {
- id,
- type: attachmentType,
- name: file.name,
- file,
- status: { type: "running", reason: "uploading", progress: 0 },
- } as PendingAttachment;
-
- try {
- // For images, convert to data URL so we can display them after persistence
- if (attachmentType === "image") {
- const dataUrl = await fileToDataUrl(file);
- imageDataUrls.set(id, dataUrl);
- }
-
- // Process the file through the backend ETL service
- const result = await processAttachment(file);
-
- // Verify we have the required fields
- if (!result.content) {
- console.error("[AttachmentAdapter] WARNING: No content received from backend!");
- }
-
- // Store the processed result for send()
- processedAttachments.set(id, result);
-
- // Create the final pending attachment
- // IMPORTANT: Use "running" status with progress: 100 to indicate processing is done
- // but attachment is still pending. The "complete" status will be set by send().
- // Yield the final state to ensure it gets processed by the UI
- yield {
- id,
- type: result.type,
- name: result.name,
- file,
- status: { type: "running", reason: "uploading", progress: 100 },
- } as PendingAttachment;
- } catch (error) {
- console.error("[AttachmentAdapter] Failed to process attachment:", error);
- throw error;
- }
- },
-
- /**
- * Called when user sends the message.
- * Converts the pending attachment to a complete attachment.
- */
- async send(pendingAttachment: PendingAttachment): Promise {
- const result = processedAttachments.get(pendingAttachment.id);
- const imageDataUrl = imageDataUrls.get(pendingAttachment.id);
-
- if (result) {
- // Clean up stored result
- processedAttachments.delete(pendingAttachment.id);
- if (imageDataUrl) {
- imageDataUrls.delete(pendingAttachment.id);
- }
-
- return {
- id: result.id,
- type: result.type,
- name: result.name,
- contentType: "text/markdown",
- status: { type: "complete" },
- content: [
- {
- type: "text",
- text: result.content,
- },
- ],
- extractedContent: result.content,
- imageDataUrl, // Store data URL for images so they can be displayed after persistence
- };
- }
-
- // Fallback if no processed result found
- console.warn(
- "[AttachmentAdapter] send() - No processed result found for attachment:",
- pendingAttachment.id
- );
- return {
- id: pendingAttachment.id,
- type: pendingAttachment.type,
- name: pendingAttachment.name,
- contentType: "text/plain",
- status: { type: "complete" },
- content: [],
- extractedContent: "",
- imageDataUrl, // Still include data URL if available
- };
- },
-
- async remove() {
- // No server-side cleanup needed since we don't persist attachments
- },
- };
-}
-
-/**
- * Extract attachment content for chat request
- *
- * This function extracts the content from attachments to be sent with the chat request.
- * Only attachments that have been fully processed (have content) will be included.
- */
-export function extractAttachmentContent(
- attachments: Array
-): Array<{ id: string; name: string; type: string; content: string }> {
- return attachments
- .filter((att): att is ChatAttachment => {
- if (!att || typeof att !== "object") return false;
- const a = att as Record;
- // Check for our custom extractedContent field first
- if (typeof a.extractedContent === "string" && a.extractedContent.length > 0) {
- return true;
- }
- // Fallback: check if content array has text content
- if (Array.isArray(a.content)) {
- const textContent = (a.content as Array<{ type: string; text?: string }>).find(
- (c) => c.type === "text" && typeof c.text === "string" && c.text.length > 0
- );
- return Boolean(textContent);
- }
- return false;
- })
- .map((att) => {
- // Get content from extractedContent or from content array
- let content = "";
- if (typeof att.extractedContent === "string") {
- content = att.extractedContent;
- } else if (Array.isArray(att.content)) {
- const textContent = (att.content as Array<{ type: string; text?: string }>).find(
- (c) => c.type === "text"
- );
- content = textContent?.text || "";
- }
-
- return {
- id: att.id,
- name: att.name,
- type: att.type,
- content,
- };
- });
-}
diff --git a/surfsense_web/lib/chat/message-utils.ts b/surfsense_web/lib/chat/message-utils.ts
index 868ed28eb..81538731b 100644
--- a/surfsense_web/lib/chat/message-utils.ts
+++ b/surfsense_web/lib/chat/message-utils.ts
@@ -1,46 +1,9 @@
import type { ThreadMessageLike } from "@assistant-ui/react";
-import { z } from "zod";
import type { MessageRecord } from "./thread-persistence";
-/**
- * Zod schema for persisted attachment info
- */
-const PersistedAttachmentSchema = z.object({
- id: z.string(),
- name: z.string(),
- type: z.string(),
- contentType: z.string().optional(),
- imageDataUrl: z.string().optional(),
- extractedContent: z.string().optional(),
-});
-
-const AttachmentsPartSchema = z.object({
- type: z.literal("attachments"),
- items: z.array(PersistedAttachmentSchema),
-});
-
-type PersistedAttachment = z.infer;
-
-/**
- * Extract persisted attachments from message content (type-safe with Zod)
- */
-function extractPersistedAttachments(content: unknown): PersistedAttachment[] {
- if (!Array.isArray(content)) return [];
-
- for (const part of content) {
- const result = AttachmentsPartSchema.safeParse(part);
- if (result.success) {
- return result.data.items;
- }
- }
-
- return [];
-}
-
/**
* Convert backend message to assistant-ui ThreadMessageLike format
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
- * Restores attachments for user messages from persisted data
*/
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
let content: ThreadMessageLike["content"];
@@ -52,7 +15,7 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
const filteredContent = msg.content.filter((part: unknown) => {
if (typeof part !== "object" || part === null || !("type" in part)) return true;
const partType = (part as { type: string }).type;
- // Filter out thinking-steps, mentioned-documents, and attachments
+ // Filter out metadata parts not directly renderable by assistant-ui
return (
partType !== "thinking-steps" &&
partType !== "mentioned-documents" &&
@@ -67,25 +30,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
content = [{ type: "text", text: String(msg.content) }];
}
- // Restore attachments for user messages
- let attachments: ThreadMessageLike["attachments"];
- if (msg.role === "user") {
- const persistedAttachments = extractPersistedAttachments(msg.content);
- if (persistedAttachments.length > 0) {
- attachments = persistedAttachments.map((att) => ({
- id: att.id,
- name: att.name,
- type: att.type as "document" | "image" | "file",
- contentType: att.contentType || "application/octet-stream",
- status: { type: "complete" as const },
- content: [],
- // Custom fields for our ChatAttachment interface
- imageDataUrl: att.imageDataUrl,
- extractedContent: att.extractedContent,
- }));
- }
- }
-
// Build metadata.custom for author display in shared chats
const metadata = msg.author_id
? {
@@ -103,7 +47,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
role: msg.role,
content,
createdAt: new Date(msg.created_at),
- attachments,
metadata,
};
}
diff --git a/surfsense_web/lib/connectors/utils.ts b/surfsense_web/lib/connectors/utils.ts
index 0ca1c1ea9..27da40cc3 100644
--- a/surfsense_web/lib/connectors/utils.ts
+++ b/surfsense_web/lib/connectors/utils.ts
@@ -1,15 +1,18 @@
// Helper function to get connector type display name
export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record = {
+ SERPER_API: "Serper API",
TAVILY_API: "Tavily API",
SEARXNG_API: "SearxNG",
+ LINKUP_API: "Linkup",
+ BAIDU_SEARCH_API: "Baidu Search",
SLACK_CONNECTOR: "Slack",
+ TEAMS_CONNECTOR: "Microsoft Teams",
NOTION_CONNECTOR: "Notion",
GITHUB_CONNECTOR: "GitHub",
LINEAR_CONNECTOR: "Linear",
JIRA_CONNECTOR: "Jira",
DISCORD_CONNECTOR: "Discord",
- LINKUP_API: "Linkup",
CONFLUENCE_CONNECTOR: "Confluence",
BOOKSTACK_CONNECTOR: "BookStack",
CLICKUP_CONNECTOR: "ClickUp",
@@ -23,8 +26,10 @@ export const getConnectorTypeDisplay = (type: string): string => {
LUMA_CONNECTOR: "Luma",
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
WEBCRAWLER_CONNECTOR: "Web Pages",
+ YOUTUBE_CONNECTOR: "YouTube",
CIRCLEBACK_CONNECTOR: "Circleback",
OBSIDIAN_CONNECTOR: "Obsidian",
+ MCP_CONNECTOR: "MCP Server",
};
return typeMap[type] || type;
};
diff --git a/surfsense_web/package.json b/surfsense_web/package.json
index ba5045001..038bdc47e 100644
--- a/surfsense_web/package.json
+++ b/surfsense_web/package.json
@@ -1,6 +1,6 @@
{
"name": "surfsense_web",
- "version": "0.0.12",
+ "version": "0.0.13",
"private": true,
"description": "SurfSense Frontend",
"scripts": {
diff --git a/surfsense_web/public/connectors/circleback.svg b/surfsense_web/public/connectors/circleback.svg
new file mode 100644
index 000000000..76bdcddd8
--- /dev/null
+++ b/surfsense_web/public/connectors/circleback.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/surfsense_web/public/connectors/composio.svg b/surfsense_web/public/connectors/composio.svg
deleted file mode 100644
index 7c06babeb..000000000
--- a/surfsense_web/public/connectors/composio.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/surfsense_web/public/connectors/linkup.svg b/surfsense_web/public/connectors/linkup.svg
new file mode 100644
index 000000000..8b0ffb071
--- /dev/null
+++ b/surfsense_web/public/connectors/linkup.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+