= ({
{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/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("chats") || "Private Chats"}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-9 pr-8 h-9"
- />
- {searchQuery && (
-
- )}
-
-
-
- {!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 ? (
-
- ) : (
-
-
-
-
-
-
- {t("updated") || "Updated"}:{" "}
- {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
-
-
-
- )}
-
-
setOpenDropdownId(isOpen ? thread.id : null)}
- >
-
-
-
-
- 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 ? (
+
+ ) : (
+
+
+
+
+
+
+ {t("updated") || "Updated"}:{" "}
+ {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
+
+
+
+ )}
+
+
setOpenDropdownId(isOpen ? thread.id : null)}
+ >
+
+
+
+
+ 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("shared_chats") || "Shared Chats"}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-9 pr-8 h-9"
- />
- {searchQuery && (
-
- )}
-
-
-
- {!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 ? (
-
- ) : (
-
-
-
-
-
-
- {t("updated") || "Updated"}:{" "}
- {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
-
-
-
- )}
-
-
setOpenDropdownId(isOpen ? thread.id : null)}
- >
-
-
-
-
- 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 ? (
+
+ ) : (
+
+
+
+
+
+
+ {t("updated") || "Updated"}:{" "}
+ {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
+
+
+
+ )}
+
+
setOpenDropdownId(isOpen ? thread.id : null)}
+ >
+
+
+
+
+ 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 (
-
-