From d761ca199211c1a35c9d8f5d7a9bcf852c1a7a35 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:47:18 -0500 Subject: [PATCH 01/32] Added chat renaming and auto naming by the active LLM --- surfsense_backend/app/prompts/__init__.py | 30 +++++++ .../app/routes/new_chat_routes.py | 26 +----- .../app/services/new_streaming_service.py | 25 ++++++ .../app/tasks/chat/stream_new_chat.py | 75 ++++++++++++++++ .../new-chat/[[...chat_id]]/page.tsx | 21 ++++- .../layout/providers/LayoutDataProvider.tsx | 88 ++++++++++++++++++- .../layout/ui/shell/LayoutShell.tsx | 4 + .../layout/ui/sidebar/ChatListItem.tsx | 33 ++++--- .../layout/ui/sidebar/MobileSidebar.tsx | 3 + .../components/layout/ui/sidebar/Sidebar.tsx | 4 + surfsense_web/messages/en.json | 7 ++ surfsense_web/messages/zh.json | 7 ++ 12 files changed, 287 insertions(+), 36 deletions(-) diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index 3b21cb9e1..efa31d612 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -104,3 +104,33 @@ SUMMARY_PROMPT = ( SUMMARY_PROMPT_TEMPLATE = PromptTemplate( input_variables=["document"], template=SUMMARY_PROMPT ) + +# ============================================================================= +# Chat Title Generation Prompt +# ============================================================================= + +TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation. + + +- The title MUST be between 1 and 6 words +- The title MUST be on a single line +- Capture the main topic or intent of the conversation +- Do NOT use quotes, punctuation, or formatting +- Do NOT include words like "Chat about" or "Discussion of" +- Return ONLY the title, nothing else + + + +{user_query} + + + +{assistant_response} + + +Title:""" + +TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( + input_variables=["user_query", "assistant_response"], + template=TITLE_GENERATION_PROMPT, +) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 42b8a821b..06e929997 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -886,30 +886,8 @@ async def append_message( # Update thread's updated_at timestamp thread.updated_at = datetime.now(UTC) - # Auto-generate title from first user message if title is still default - if thread.title == "New Chat" and role_str == "user": - # Extract text content for title - content = message.content - if isinstance(content, str): - title_text = content - elif isinstance(content, list): - # Find first text content - title_text = "" - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - title_text = part.get("text", "") - break - elif isinstance(part, str): - title_text = part - break - else: - title_text = str(content) - - # Truncate title - if title_text: - thread.title = title_text[:100] + ( - "..." if len(title_text) > 100 else "" - ) + # Note: Title generation now happens in stream_new_chat.py after the first response + # using LLM to generate a descriptive title (with truncation as fallback) await session.commit() await session.refresh(db_message) diff --git a/surfsense_backend/app/services/new_streaming_service.py b/surfsense_backend/app/services/new_streaming_service.py index 05dd2d4dd..57fbc9663 100644 --- a/surfsense_backend/app/services/new_streaming_service.py +++ b/surfsense_backend/app/services/new_streaming_service.py @@ -479,6 +479,31 @@ class VercelStreamingService: }, ) + def format_thread_title_update(self, thread_id: int, title: str) -> str: + """ + Format a thread title update notification (SurfSense specific). + + This is sent after the first response in a thread to update the + auto-generated title based on the conversation content. + + Args: + thread_id: The ID of the thread being updated + title: The new title for the thread + + Returns: + str: SSE formatted thread title update data part + + Example output: + data: {"type":"data-thread-title-update","data":{"threadId":123,"title":"New Title"}} + """ + return self.format_data( + "thread-title-update", + { + "threadId": thread_id, + "title": title, + }, + ) + # ========================================================================= # Error Part # ========================================================================= diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 688777203..47e2a2293 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -32,6 +32,7 @@ from app.services.chat_session_state_service import ( clear_ai_responding, set_ai_responding, ) +from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService from app.utils.content_utils import bootstrap_history_from_db @@ -1208,6 +1209,80 @@ async def stream_new_chat( if completion_event: yield completion_event + # Generate LLM title for new chats after first response + # Check if this is the first assistant response by counting existing assistant messages + from app.db import NewChatMessage, NewChatThread + from sqlalchemy import func + + assistant_count_result = await session.execute( + select(func.count(NewChatMessage.id)).filter( + NewChatMessage.thread_id == chat_id, + NewChatMessage.role == "assistant", + ) + ) + assistant_message_count = assistant_count_result.scalar() or 0 + + # Only generate title on the first response (no prior assistant messages) + if assistant_message_count == 0: + print(f"[stream_new_chat] First response - generating title for thread {chat_id}") + print(f"[stream_new_chat] Query length: {len(user_query)}, Response length: {len(accumulated_text)}") + + generated_title = None + try: + # Generate title using the same LLM + title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm + # Truncate inputs to avoid context length issues + truncated_query = user_query[:500] + truncated_response = accumulated_text[:1000] + print(f"[stream_new_chat] Calling LLM for title generation...") + title_result = await title_chain.ainvoke({ + "user_query": truncated_query, + "assistant_response": truncated_response, + }) + print(f"[stream_new_chat] LLM title result type: {type(title_result)}") + print(f"[stream_new_chat] LLM title result: {title_result}") + + # Extract and clean the title + if title_result and hasattr(title_result, "content"): + raw_title = title_result.content.strip() + print(f"[stream_new_chat] Raw title content: '{raw_title}' (len={len(raw_title)})") + + # Validate the title (1-6 words, reasonable length) + if raw_title and len(raw_title) <= 100: + # Remove any quotes or extra formatting + generated_title = raw_title.strip('"\'') + print(f"[stream_new_chat] After stripping quotes: '{generated_title}'") + else: + print(f"[stream_new_chat] Title validation failed: empty={not raw_title}, len={len(raw_title)}") + generated_title = None + else: + print(f"[stream_new_chat] No content attribute on result") + except Exception as title_error: + print(f"[stream_new_chat] Title generation failed: {title_error}") + import traceback + traceback.print_exc() + generated_title = None + + # Only update if LLM succeeded (keep truncated prompt title as fallback) + if generated_title: + print(f"[stream_new_chat] Using LLM-generated title: '{generated_title}'") + + # Fetch thread and update title + thread_result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + thread = thread_result.scalars().first() + if thread: + thread.title = generated_title + await session.commit() + + # Notify frontend of the title update + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) + else: + print(f"[stream_new_chat] LLM title generation failed, keeping truncated prompt title") + # Finish the step and message yield streaming_service.format_finish_step() yield streaming_service.format_finish() diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9b462fcbc..4f99f8021 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -437,7 +437,10 @@ export default function NewChatPage() { let isNewThread = false; if (!currentThreadId) { try { - const newThread = await createThread(searchSpaceId, "New Chat"); + // Create thread with truncated prompt as initial title + const initialTitle = + userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : ""); + const newThread = await createThread(searchSpaceId, initialTitle); currentThreadId = newThread.id; setThreadId(currentThreadId); // Set currentThread so ChatHeader can show share button immediately @@ -827,6 +830,22 @@ export default function NewChatPage() { break; } + case "data-thread-title-update": { + // Handle thread title update from LLM-generated title + const titleData = parsed.data as { threadId: number; title: string }; + if (titleData?.title && titleData?.threadId === currentThreadId) { + // Update current thread state with new title + setCurrentThread((prev) => + prev ? { ...prev, title: titleData.title } : prev + ); + // Invalidate thread list to refresh sidebar + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId)], + }); + } + break; + } + case "error": throw new Error(parsed.errorText || "Server error"); } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 2f71adad9..9a5c2ed40 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react"; +import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -21,6 +21,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +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"; @@ -207,6 +208,12 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); + // Rename dialog state + const [showRenameChatDialog, setShowRenameChatDialog] = useState(false); + const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null); + const [newChatTitle, setNewChatTitle] = useState(""); + const [isRenamingChat, setIsRenamingChat] = useState(false); + // Delete/Leave search space dialog state const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); @@ -421,6 +428,12 @@ export function LayoutDataProvider({ setShowDeleteChatDialog(true); }, []); + const handleChatRename = useCallback((chat: ChatItem) => { + setChatToRename({ id: chat.id, name: chat.name }); + setNewChatTitle(chat.name); + setShowRenameChatDialog(true); + }, []); + const handleChatArchive = useCallback( async (chat: ChatItem) => { const newArchivedState = !chat.archived; @@ -501,6 +514,27 @@ export function LayoutDataProvider({ } }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); + // Rename handler + const confirmRenameChat = useCallback(async () => { + if (!chatToRename || !newChatTitle.trim()) return; + setIsRenamingChat(true); + try { + await updateThread(chatToRename.id, { title: newChatTitle.trim() }); + toast.success(tSidebar("chat_renamed") || "Chat renamed"); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + } catch (error) { + console.error("Error renaming thread:", error); + toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); + } finally { + setIsRenamingChat(false); + setShowRenameChatDialog(false); + setChatToRename(null); + setNewChatTitle(""); + } + }, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]); + // Page usage const pageUsage = user ? { @@ -529,6 +563,7 @@ export function LayoutDataProvider({ activeChatId={currentChatId} onNewChat={handleNewChat} onChatSelect={handleChatSelect} + onChatRename={handleChatRename} onChatDelete={handleChatDelete} onChatArchive={handleChatArchive} onViewAllSharedChats={handleViewAllSharedChats} @@ -620,6 +655,57 @@ export function LayoutDataProvider({ + {/* Rename Chat Dialog */} + + + + + + {tSidebar("rename_chat") || "Rename Chat"} + + + {tSidebar("rename_chat_description") || "Enter a new name for this conversation."} + + + setNewChatTitle(e.target.value)} + placeholder={tSidebar("chat_title_placeholder") || "Chat title"} + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) { + confirmRenameChat(); + } + }} + /> + + + + + + + {/* Delete Search Space Dialog */} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 3624c90a3..8eae99b03 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -54,6 +54,7 @@ interface LayoutShellProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -90,6 +91,7 @@ export function LayoutShell({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -147,6 +149,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} @@ -215,6 +218,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 6db6782d0..ba2989145 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react"; +import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { @@ -17,6 +17,7 @@ interface ChatListItemProps { isActive?: boolean; archived?: boolean; onClick?: () => void; + onRename?: () => void; onArchive?: () => void; onDelete?: () => void; } @@ -26,6 +27,7 @@ export function ChatListItem({ isActive, archived, onClick, + onRename, onArchive, onDelete, }: ChatListItemProps) { @@ -57,15 +59,26 @@ export function ChatListItem({ {t("more_options")} - - {onArchive && ( - { - e.stopPropagation(); - onArchive(); - }} - > - {archived ? ( + + {onRename && ( + { + e.stopPropagation(); + onRename(); + }} + > + + {t("rename") || "Rename"} + + )} + {onArchive && ( + { + e.stopPropagation(); + onArchive(); + }} + > + {archived ? ( <> {t("unarchive") || "Restore"} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 85f907611..3ed2f9cca 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -24,6 +24,7 @@ interface MobileSidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -64,6 +65,7 @@ export function MobileSidebar({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -142,6 +144,7 @@ export function MobileSidebar({ onOpenChange(false); }} onChatSelect={handleChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index db04bf6dc..8763056ed 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -25,6 +25,7 @@ interface SidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -51,6 +52,7 @@ export function Sidebar({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -163,6 +165,7 @@ export function Sidebar({ isActive={chat.id === activeChatId} archived={chat.archived} onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} onDelete={() => onChatDelete?.(chat)} /> @@ -215,6 +218,7 @@ export function Sidebar({ isActive={chat.id === activeChatId} archived={chat.archived} onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} onDelete={() => onChatDelete?.(chat)} /> diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 75b186420..b44287af5 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -676,6 +676,13 @@ "unarchive": "Restore", "chat_archived": "Chat archived", "chat_unarchived": "Chat restored", + "chat_renamed": "Chat renamed", + "error_renaming_chat": "Failed to rename chat", + "rename": "Rename", + "rename_chat": "Rename Chat", + "rename_chat_description": "Enter a new name for this conversation.", + "chat_title_placeholder": "Chat title", + "renaming": "Renaming...", "no_archived_chats": "No archived chats", "error_archiving_chat": "Failed to archive chat", "new_chat": "New chat", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 81121ef3e..cbcef3691 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -661,6 +661,13 @@ "unarchive": "恢复", "chat_archived": "对话已归档", "chat_unarchived": "对话已恢复", + "chat_renamed": "对话已重命名", + "error_renaming_chat": "重命名对话失败", + "rename": "重命名", + "rename_chat": "重命名对话", + "rename_chat_description": "为此对话输入新名称。", + "chat_title_placeholder": "对话标题", + "renaming": "重命名中...", "no_archived_chats": "暂无已归档对话", "error_archiving_chat": "归档对话失败", "new_chat": "新对话", From 07f89a426882d663ff9ac2b14a84850a7a7251b0 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:54:21 -0500 Subject: [PATCH 02/32] Show the chat title in the breadcrumbs instead of "Chat" --- .../new-chat/[[...chat_id]]/page.tsx | 4 ++++ .../components/dashboard-breadcrumb.tsx | 16 ++++++++++++++-- .../layout/providers/LayoutDataProvider.tsx | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 4f99f8021..1a535539d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -842,6 +842,10 @@ export default function NewChatPage() { queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)], }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)], + }); } break; } diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 96bd0ef30..5c6399ce0 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/breadcrumb"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; +import { getThreadFull } from "@/lib/chat/thread-persistence"; import { cacheKeys } from "@/lib/query-client/cache-keys"; interface BreadcrumbItemInterface { @@ -34,6 +35,16 @@ export function DashboardBreadcrumb() { enabled: !!searchSpaceId, }); + // Extract chat thread ID from pathname for chat pages + const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null; + + // Fetch thread details when on a chat page with a thread ID + const { data: threadData } = useQuery({ + queryKey: ["threads", searchSpaceId, "detail", chatThreadId], + queryFn: () => getThreadFull(Number(chatThreadId)), + enabled: !!chatThreadId && !!searchSpaceId, + }); + // State to store document title for editor breadcrumb const [documentTitle, setDocumentTitle] = useState(null); @@ -144,10 +155,11 @@ export function DashboardBreadcrumb() { } // Handle new-chat sub-sections (thread IDs) - // Don't show thread ID in breadcrumb - users identify chats by content, not by ID + // Show the chat title if available, otherwise fall back to "Chat" if (section === "new-chat") { + const chatLabel = threadData?.title || t("chat") || "Chat"; breadcrumbs.push({ - label: t("chat") || "Chat", + label: chatLabel, }); return breadcrumbs; } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 9a5c2ed40..4d8e2d23a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -524,6 +524,8 @@ export function LayoutDataProvider({ queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] }); } catch (error) { console.error("Error renaming thread:", error); toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); From 318ad4a4ba2ff5a6e8edf4837846c649471e9606 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:59:50 -0500 Subject: [PATCH 03/32] Removed excessive logging around chat title generation --- .../app/tasks/chat/stream_new_chat.py | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 47e2a2293..a9751e5d1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1224,9 +1224,6 @@ async def stream_new_chat( # Only generate title on the first response (no prior assistant messages) if assistant_message_count == 0: - print(f"[stream_new_chat] First response - generating title for thread {chat_id}") - print(f"[stream_new_chat] Query length: {len(user_query)}, Response length: {len(accumulated_text)}") - generated_title = None try: # Generate title using the same LLM @@ -1234,39 +1231,23 @@ async def stream_new_chat( # Truncate inputs to avoid context length issues truncated_query = user_query[:500] truncated_response = accumulated_text[:1000] - print(f"[stream_new_chat] Calling LLM for title generation...") title_result = await title_chain.ainvoke({ "user_query": truncated_query, "assistant_response": truncated_response, }) - print(f"[stream_new_chat] LLM title result type: {type(title_result)}") - print(f"[stream_new_chat] LLM title result: {title_result}") # Extract and clean the title if title_result and hasattr(title_result, "content"): raw_title = title_result.content.strip() - print(f"[stream_new_chat] Raw title content: '{raw_title}' (len={len(raw_title)})") - - # Validate the title (1-6 words, reasonable length) + # Validate the title (reasonable length) if raw_title and len(raw_title) <= 100: # Remove any quotes or extra formatting generated_title = raw_title.strip('"\'') - print(f"[stream_new_chat] After stripping quotes: '{generated_title}'") - else: - print(f"[stream_new_chat] Title validation failed: empty={not raw_title}, len={len(raw_title)}") - generated_title = None - else: - print(f"[stream_new_chat] No content attribute on result") - except Exception as title_error: - print(f"[stream_new_chat] Title generation failed: {title_error}") - import traceback - traceback.print_exc() + except Exception: generated_title = None # Only update if LLM succeeded (keep truncated prompt title as fallback) if generated_title: - print(f"[stream_new_chat] Using LLM-generated title: '{generated_title}'") - # Fetch thread and update title thread_result = await session.execute( select(NewChatThread).filter(NewChatThread.id == chat_id) @@ -1280,8 +1261,6 @@ async def stream_new_chat( yield streaming_service.format_thread_title_update( chat_id, generated_title ) - else: - print(f"[stream_new_chat] LLM title generation failed, keeping truncated prompt title") # Finish the step and message yield streaming_service.format_finish_step() From 5bee04192c14f516e292f9978f1c85a30ebc9baf Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 22:19:10 -0500 Subject: [PATCH 04/32] Overhauled pricing plans. --- surfsense_web/components/pricing.tsx | 5 +- .../components/pricing/pricing-section.tsx | 73 ++++++++++--------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx index 39757e91e..9d05e0262 100644 --- a/surfsense_web/components/pricing.tsx +++ b/surfsense_web/components/pricing.tsx @@ -17,6 +17,7 @@ interface PricingPlan { price: string; yearlyPrice: string; period: string; + billingText?: string; features: string[]; description: string; buttonText: string; @@ -35,7 +36,7 @@ export function Pricing({ title = "Simple, Transparent Pricing", description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.", }: PricingProps) { - const [isMonthly, setIsMonthly] = useState(true); + const [isMonthly, setIsMonthly] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); const switchRef = useRef(null); @@ -183,7 +184,7 @@ export function Pricing({

- {isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"} + {plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}

    diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index fdad0796a..5b3513e51 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -4,44 +4,47 @@ import { Pricing } from "@/components/pricing"; const demoPlans = [ { - name: "COMMUNITY", + name: "FREE", price: "0", yearlyPrice: "0", - period: "forever", + period: "", + billingText: "Includes 30 day PRO trial", features: [ - "Community support", - "Supports 100+ LLMs", - "Supports OpenAI spec and LiteLLM", - "Supports local vLLM or Ollama setups", - "6000+ embedding models", + "Open source on GitHub", + "Upload and chat with up to 1,000 pages of content", + "Connects with 8 popular sources, like Drive and Notion.", + "Includes limited access to ChatGPT, Claude, and DeepSeek models", + "Supports 100+ more LLMs, including Gemini, Llama and many more.", "50+ File extensions supported.", - "Podcasts support with local TTS providers.", - "Connects with 15+ external sources, like Drive and Notion.", + "Generate podcasts in seconds.", "Cross-Browser Extension for dynamic webpages including authenticated content", - "Role-based access control (RBAC)", - "Collaboration and team features", + "Community support on Discord", ], - description: "Open source version with powerful features", - buttonText: "Dive In", - href: "/docs", + description: "Powerful features with some limitations", + buttonText: "Get Started", + href: "/", isPopular: false, }, { - name: "CLOUD", - price: "0", - yearlyPrice: "0", - period: "in beta", + name: "PRO", + price: "10", + yearlyPrice: "10", + period: "user / month", + billingText: "billed annually", features: [ - "Everything in Community", - "Email support", - "Get started in seconds", - "Instant access to new features", - "Easy access from anywhere", - "Remote team management and collaboration", + "Everything in Free", + "Upload and chat with up to 20,000 pages of content", + "Connects with 15+ external sources, like Slack and Airtable.", + "Includes extended access to ChatGPT, Claude, and DeepSeek models", + "Collaboration and commenting features", + "Centralized billing", + "Shared BYOK (Bring Your Own Key)", + "Team and role management", + "Priority support", ], - description: "Instant access for individuals and teams", - buttonText: "Get Started", - href: "/", + description: "The AIknowledge base for individuals and teams", + buttonText: "Upgrade", + href: "/contact", isPopular: true, }, { @@ -50,17 +53,19 @@ const demoPlans = [ yearlyPrice: "Contact Us", period: "", features: [ - "Everything in Community", - "Priority support", - "White-glove setup and deployment", - "Monthly managed updates and maintenance", + "Everything in Pro", + "Connect and chat with virtually unlimited pages of content", + "Limit models and/or providers", "On-prem or VPC deployment", "Audit logs and compliance", "SSO, OIDC & SAML", - "SLA guarantee", - "Uptime guarantee on VPC", + "Role-based access control (RBAC)", + "White-glove setup and deployment", + "Monthly managed updates and maintenance", + "SLA commitments", + "Dedicated support", ], - description: "Professional, customized setup for large organizations", + description: "Customized setup for large organizations", buttonText: "Contact Sales", href: "/contact", isPopular: false, From 40a304bd0b15ab834e0ba2523796413727e941eb Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 22:49:38 -0500 Subject: [PATCH 05/32] Updated enterprise pricing plan t. --- surfsense_web/components/pricing/pricing-section.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 5b3513e51..c850852d8 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -49,9 +49,10 @@ const demoPlans = [ }, { name: "ENTERPRISE", - price: "Contact Us", - yearlyPrice: "Contact Us", - period: "", + price: "1000", + yearlyPrice: "1000", + period: "month", + billingText: "billed annually", features: [ "Everything in Pro", "Connect and chat with virtually unlimited pages of content", From fb333fdd541cb37e340759a0630e1e86532dd706 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 3 Feb 2026 21:39:18 +0200 Subject: [PATCH 06/32] Add CommentReplyNotificationHandler for comment thread notifications --- .../app/services/notification_service.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py index 1788d05e1..a759f3536 100644 --- a/surfsense_backend/app/services/notification_service.py +++ b/surfsense_backend/app/services/notification_service.py @@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler): raise +class CommentReplyNotificationHandler(BaseNotificationHandler): + """Handler for comment reply notifications.""" + + def __init__(self): + super().__init__("comment_reply") + + async def find_notification_by_reply( + self, + session: AsyncSession, + reply_id: int, + user_id: UUID, + ) -> Notification | None: + query = select(Notification).where( + Notification.type == self.notification_type, + Notification.user_id == user_id, + Notification.notification_metadata["reply_id"].astext == str(reply_id), + ) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def notify_comment_reply( + self, + session: AsyncSession, + user_id: UUID, + reply_id: int, + parent_comment_id: int, + message_id: int, + thread_id: int, + thread_title: str, + author_id: str, + author_name: str, + author_avatar_url: str | None, + author_email: str, + content_preview: str, + search_space_id: int, + ) -> Notification: + existing = await self.find_notification_by_reply(session, reply_id, user_id) + if existing: + logger.info( + f"Notification already exists for reply {reply_id} to user {user_id}" + ) + return existing + + title = f"{author_name} replied in a thread" + message = content_preview[:100] + ("..." if len(content_preview) > 100 else "") + + metadata = { + "reply_id": reply_id, + "parent_comment_id": parent_comment_id, + "message_id": message_id, + "thread_id": thread_id, + "thread_title": thread_title, + "author_id": author_id, + "author_name": author_name, + "author_avatar_url": author_avatar_url, + "author_email": author_email, + "content_preview": content_preview[:200], + } + + try: + notification = Notification( + user_id=user_id, + search_space_id=search_space_id, + type=self.notification_type, + title=title, + message=message, + notification_metadata=metadata, + ) + session.add(notification) + await session.commit() + await session.refresh(notification) + logger.info( + f"Created comment_reply notification {notification.id} for user {user_id}" + ) + return notification + except Exception as e: + await session.rollback() + if ( + "duplicate key" in str(e).lower() + or "unique constraint" in str(e).lower() + ): + logger.warning( + f"Duplicate notification for reply {reply_id} to user {user_id}" + ) + existing = await self.find_notification_by_reply( + session, reply_id, user_id + ) + if existing: + return existing + raise + + class PageLimitNotificationHandler(BaseNotificationHandler): """Handler for page limit exceeded notifications.""" @@ -959,6 +1051,7 @@ class NotificationService: connector_indexing = ConnectorIndexingNotificationHandler() document_processing = DocumentProcessingNotificationHandler() mention = MentionNotificationHandler() + comment_reply = CommentReplyNotificationHandler() page_limit = PageLimitNotificationHandler() @staticmethod From bf91d0c3d21887f909225f50f9b7c55a0523d830 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 3 Feb 2026 21:57:07 +0200 Subject: [PATCH 07/32] Add get_comment_thread_participants helper function --- .../app/services/chat_comments_service.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index dc3b51238..7350dc12f 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -5,7 +5,7 @@ Service layer for chat comments and mentions. from uuid import UUID from fastapi import HTTPException -from sqlalchemy import delete, select +from sqlalchemy import delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -103,6 +103,37 @@ async def process_mentions( return mentions_map +async def get_comment_thread_participants( + session: AsyncSession, + parent_comment_id: int, + exclude_user_ids: set[UUID], +) -> list[UUID]: + """ + Get all unique authors in a comment thread (parent + replies), excluding specified users. + + Args: + session: Database session + parent_comment_id: ID of the parent comment + exclude_user_ids: Set of user IDs to exclude (e.g., replier, mentioned users) + + Returns: + List of user UUIDs who have participated in the thread + """ + query = select(ChatComment.author_id).where( + or_( + ChatComment.id == parent_comment_id, + ChatComment.parent_id == parent_comment_id, + ), + ChatComment.author_id.isnot(None), + ) + + if exclude_user_ids: + query = query.where(ChatComment.author_id.notin_(exclude_user_ids)) + + result = await session.execute(query.distinct()) + return [row[0] for row in result.fetchall()] + + async def get_comments_for_message( session: AsyncSession, message_id: int, From cf512153df2fdfccaca577ceeff9ea6cc4e161ed Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 3 Feb 2026 22:00:26 +0200 Subject: [PATCH 08/32] Notify thread participants on new reply --- .../app/services/chat_comments_service.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index 7350dc12f..209606359 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -467,6 +467,28 @@ async def create_reply( search_space_id=search_space_id, ) + # Notify thread participants (excluding replier and mentioned users) + exclude_ids = {user.id, *mentions_map.keys()} + participants = await get_comment_thread_participants( + session, comment_id, exclude_ids + ) + for participant_id in participants: + await NotificationService.comment_reply.notify_comment_reply( + session=session, + user_id=participant_id, + reply_id=reply.id, + parent_comment_id=comment_id, + message_id=parent_comment.message_id, + thread_id=thread.id, + thread_title=thread.title or "Untitled thread", + author_id=str(user.id), + author_name=author_name, + author_avatar_url=user.avatar_url, + author_email=user.email, + content_preview=content_preview[:200], + search_space_id=search_space_id, + ) + author = AuthorResponse( id=user.id, display_name=user.display_name, From 1c8ec7bbdcd13dc0dfd7158f557a6ee39d33e69c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 14:45:28 +0200 Subject: [PATCH 09/32] Add comment_reply notification type to frontend types --- surfsense_web/contracts/types/inbox.types.ts | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 8e4b9ae86..ebf1889a1 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -10,6 +10,7 @@ export const inboxItemTypeEnum = z.enum([ "connector_deletion", "document_processing", "new_mention", + "comment_reply", "page_limit_exceeded", ]); @@ -101,6 +102,19 @@ export const newMentionMetadata = z.object({ content_preview: z.string(), }); +export const commentReplyMetadata = z.object({ + reply_id: z.number(), + parent_comment_id: z.number(), + message_id: z.number(), + thread_id: z.number(), + thread_title: z.string(), + author_id: z.string(), + author_name: z.string(), + author_avatar_url: z.string().nullable().optional(), + author_email: z.string().optional(), + content_preview: z.string(), +}); + /** * Page limit exceeded metadata schema */ @@ -125,6 +139,7 @@ export const inboxItemMetadata = z.union([ connectorDeletionMetadata, documentProcessingMetadata, newMentionMetadata, + commentReplyMetadata, pageLimitExceededMetadata, baseInboxItemMetadata, ]); @@ -168,6 +183,11 @@ export const newMentionInboxItem = inboxItem.extend({ metadata: newMentionMetadata, }); +export const commentReplyInboxItem = inboxItem.extend({ + type: z.literal("comment_reply"), + metadata: commentReplyMetadata, +}); + export const pageLimitExceededInboxItem = inboxItem.extend({ type: z.literal("page_limit_exceeded"), metadata: pageLimitExceededMetadata, @@ -278,6 +298,10 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM return newMentionMetadata.safeParse(metadata).success; } +export function isCommentReplyMetadata(metadata: unknown): metadata is CommentReplyMetadata { + return commentReplyMetadata.safeParse(metadata).success; +} + /** * Type guard for PageLimitExceededMetadata */ @@ -298,6 +322,7 @@ export function parseInboxItemMetadata( | ConnectorDeletionMetadata | DocumentProcessingMetadata | NewMentionMetadata + | CommentReplyMetadata | PageLimitExceededMetadata | null { switch (type) { @@ -317,6 +342,10 @@ export function parseInboxItemMetadata( const result = newMentionMetadata.safeParse(metadata); return result.success ? result.data : null; } + case "comment_reply": { + const result = commentReplyMetadata.safeParse(metadata); + return result.success ? result.data : null; + } case "page_limit_exceeded": { const result = pageLimitExceededMetadata.safeParse(metadata); return result.success ? result.data : null; @@ -338,6 +367,7 @@ export type ConnectorIndexingMetadata = z.infer; export type DocumentProcessingMetadata = z.infer; export type NewMentionMetadata = z.infer; +export type CommentReplyMetadata = z.infer; export type PageLimitExceededMetadata = z.infer; export type InboxItemMetadata = z.infer; export type InboxItem = z.infer; @@ -345,6 +375,7 @@ export type ConnectorIndexingInboxItem = z.infer; export type DocumentProcessingInboxItem = z.infer; export type NewMentionInboxItem = z.infer; +export type CommentReplyInboxItem = z.infer; export type PageLimitExceededInboxItem = z.infer; // API Request/Response types From 21a4c254583d2cb604451d226e17c66492b71fe3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 14:47:03 +0200 Subject: [PATCH 10/32] Handle comment_reply in inbox sidebar (icon + click routing) --- .../layout/ui/sidebar/InboxSidebar.tsx | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 9ef49c0d8..c1f5fcc99 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { + isCommentReplyMetadata, isConnectorIndexingMetadata, isNewMentionMetadata, isPageLimitExceededMetadata, @@ -347,19 +348,33 @@ export function InboxSidebar({ } if (item.type === "new_mention") { - // Use type guard for safe metadata access if (isNewMentionMetadata(item.metadata)) { const searchSpaceId = item.search_space_id; const threadId = item.metadata.thread_id; const commentId = item.metadata.comment_id; if (searchSpaceId && threadId) { - // Pre-set target comment ID before navigation - // This also ensures comments panel is not collapsed if (commentId) { setTargetCommentId(commentId); } + const url = commentId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` + : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } + } + } else if (item.type === "comment_reply") { + if (isCommentReplyMetadata(item.metadata)) { + const searchSpaceId = item.search_space_id; + const threadId = item.metadata.thread_id; + const commentId = item.metadata.parent_comment_id; + if (searchSpaceId && threadId) { + if (commentId) { + setTargetCommentId(commentId); + } const url = commentId ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; @@ -411,24 +426,29 @@ export function InboxSidebar({ }; const getStatusIcon = (item: InboxItem) => { - // For mentions, show the author's avatar with initials fallback - if (item.type === "new_mention") { - // Use type guard for safe metadata access - if (isNewMentionMetadata(item.metadata)) { - const authorName = item.metadata.author_name; - const avatarUrl = item.metadata.author_avatar_url; - const authorEmail = item.metadata.author_email; + // For mentions and comment replies, show the author's avatar + if (item.type === "new_mention" || item.type === "comment_reply") { + const metadata = + item.type === "new_mention" + ? isNewMentionMetadata(item.metadata) + ? item.metadata + : null + : isCommentReplyMetadata(item.metadata) + ? item.metadata + : null; + if (metadata) { return ( - {avatarUrl && } + {metadata.author_avatar_url && ( + + )} - {getInitials(authorName, authorEmail)} + {getInitials(metadata.author_name, metadata.author_email)} ); } - // Fallback for invalid metadata return ( From d5b75956c36cca3098b1b69aae747a27a9ded01e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 15:13:28 +0200 Subject: [PATCH 11/32] Include comment_reply in status tab filter --- surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index c1f5fcc99..a46ad5478 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -238,7 +238,7 @@ export function InboxSidebar({ const currentDataSource = activeTab === "mentions" ? mentions : status; const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource; - // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion + // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion, comment replies // Filter to only show status notification types const statusItems = useMemo( () => @@ -247,7 +247,8 @@ export function InboxSidebar({ item.type === "connector_indexing" || item.type === "document_processing" || item.type === "page_limit_exceeded" || - item.type === "connector_deletion" + item.type === "connector_deletion" || + item.type === "comment_reply" ), [status.items] ); From 6d1a3636830d934b76c908e24e755770bece26b6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 15:18:25 +0200 Subject: [PATCH 12/32] Navigate to reply instead of parent comment --- .../components/layout/ui/sidebar/InboxSidebar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index a46ad5478..b63e16317 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -370,14 +370,14 @@ export function InboxSidebar({ if (isCommentReplyMetadata(item.metadata)) { const searchSpaceId = item.search_space_id; const threadId = item.metadata.thread_id; - const commentId = item.metadata.parent_comment_id; + const replyId = item.metadata.reply_id; if (searchSpaceId && threadId) { - if (commentId) { - setTargetCommentId(commentId); + if (replyId) { + setTargetCommentId(replyId); } - const url = commentId - ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` + const url = replyId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; onOpenChange(false); onCloseMobileSidebar?.(); From f610e42b9c588e60843e91090540b4abaf6712d3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 16:37:44 +0200 Subject: [PATCH 13/32] fix: prevent duplicate notifications and fix inbox count --- .../app/services/chat_comments_service.py | 7 +- .../layout/providers/LayoutDataProvider.tsx | 11 +- .../components/theme/theme-toggle.tsx | 787 +++++++++--------- 3 files changed, 396 insertions(+), 409 deletions(-) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index 209606359..c9ca920f6 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -128,7 +128,7 @@ async def get_comment_thread_participants( ) if exclude_user_ids: - query = query.where(ChatComment.author_id.notin_(exclude_user_ids)) + query = query.where(ChatComment.author_id.notin_(list(exclude_user_ids))) result = await session.execute(query.distinct()) return [row[0] for row in result.fetchall()] @@ -468,11 +468,14 @@ async def create_reply( ) # Notify thread participants (excluding replier and mentioned users) - exclude_ids = {user.id, *mentions_map.keys()} + mentioned_user_ids = set(mentions_map.keys()) + exclude_ids = {user.id} | mentioned_user_ids participants = await get_comment_thread_participants( session, comment_id, exclude_ids ) for participant_id in participants: + if participant_id in mentioned_user_ids: + continue await NotificationService.comment_reply.notify_comment_reply( session=session, user_id=participant_id, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 2f71adad9..feb34940a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -109,7 +109,6 @@ export function LayoutDataProvider({ // This ensures each tab has independent pagination and data loading const userId = user?.id ? String(user.id) : null; - // Mentions: Only fetch "new_mention" type notifications const { inboxItems: mentionItems, unreadCount: mentionUnreadCount, @@ -121,11 +120,9 @@ export function LayoutDataProvider({ markAllAsRead: markAllMentionsAsRead, } = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); - // Status: Fetch all types (will be filtered client-side to status types) - // We pass null to get all, then InboxSidebar filters to status types const { inboxItems: statusItems, - unreadCount: statusUnreadCount, + unreadCount: allUnreadCount, loading: statusLoading, loadingMore: statusLoadingMore, hasMore: statusHasMore, @@ -134,8 +131,8 @@ export function LayoutDataProvider({ markAllAsRead: markAllStatusAsRead, } = useInbox(userId, Number(searchSpaceId) || null, null); - // Combined unread count for nav badge (mentions take priority for visibility) - const totalUnreadCount = mentionUnreadCount + statusUnreadCount; + const totalUnreadCount = allUnreadCount; + const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -561,7 +558,7 @@ export function LayoutDataProvider({ }, status: { items: statusItems, - unreadCount: statusUnreadCount, + unreadCount: statusOnlyUnreadCount, loading: statusLoading, loadingMore: statusLoadingMore, hasMore: statusHasMore, diff --git a/surfsense_web/components/theme/theme-toggle.tsx b/surfsense_web/components/theme/theme-toggle.tsx index 382d11087..b9b23656b 100644 --- a/surfsense_web/components/theme/theme-toggle.tsx +++ b/surfsense_web/components/theme/theme-toggle.tsx @@ -8,172 +8,167 @@ import { cn } from "@/lib/utils"; // /////////////////////////////////////////////////////////////////////////// // Types -export type AnimationVariant = - | "circle" - | "rectangle" - | "gif" - | "polygon" - | "circle-blur"; +export type AnimationVariant = "circle" | "rectangle" | "gif" | "polygon" | "circle-blur"; export type AnimationStart = - | "top-left" - | "top-right" - | "bottom-left" - | "bottom-right" - | "center" - | "top-center" - | "bottom-center" - | "bottom-up" - | "top-down" - | "left-right" - | "right-left"; + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "center" + | "top-center" + | "bottom-center" + | "bottom-up" + | "top-down" + | "left-right" + | "right-left"; interface Animation { - name: string; - css: string; + name: string; + css: string; } // /////////////////////////////////////////////////////////////////////////// // Helper functions const getPositionCoords = (position: AnimationStart) => { - switch (position) { - case "top-left": - return { cx: "0", cy: "0" }; - case "top-right": - return { cx: "40", cy: "0" }; - case "bottom-left": - return { cx: "0", cy: "40" }; - case "bottom-right": - return { cx: "40", cy: "40" }; - case "top-center": - return { cx: "20", cy: "0" }; - case "bottom-center": - return { cx: "20", cy: "40" }; - case "bottom-up": - case "top-down": - case "left-right": - case "right-left": - return { cx: "20", cy: "20" }; - } + switch (position) { + case "top-left": + return { cx: "0", cy: "0" }; + case "top-right": + return { cx: "40", cy: "0" }; + case "bottom-left": + return { cx: "0", cy: "40" }; + case "bottom-right": + return { cx: "40", cy: "40" }; + case "top-center": + return { cx: "20", cy: "0" }; + case "bottom-center": + return { cx: "20", cy: "40" }; + case "bottom-up": + case "top-down": + case "left-right": + case "right-left": + return { cx: "20", cy: "20" }; + } }; const generateSVG = (variant: AnimationVariant, start: AnimationStart) => { - if (variant === "circle-blur") { - if (start === "center") { - return `data:image/svg+xml,`; - } - const positionCoords = getPositionCoords(start); - if (!positionCoords) { - throw new Error(`Invalid start position: ${start}`); - } - const { cx, cy } = positionCoords; - return `data:image/svg+xml,`; - } + if (variant === "circle-blur") { + if (start === "center") { + return `data:image/svg+xml,`; + } + const positionCoords = getPositionCoords(start); + if (!positionCoords) { + throw new Error(`Invalid start position: ${start}`); + } + const { cx, cy } = positionCoords; + return `data:image/svg+xml,`; + } - if (start === "center") return; + if (start === "center") return; - if (variant === "rectangle") return ""; + if (variant === "rectangle") return ""; - const positionCoords = getPositionCoords(start); - if (!positionCoords) { - throw new Error(`Invalid start position: ${start}`); - } - const { cx, cy } = positionCoords; + const positionCoords = getPositionCoords(start); + if (!positionCoords) { + throw new Error(`Invalid start position: ${start}`); + } + const { cx, cy } = positionCoords; - if (variant === "circle") { - return `data:image/svg+xml,`; - } + if (variant === "circle") { + return `data:image/svg+xml,`; + } - return ""; + return ""; }; const getTransformOrigin = (start: AnimationStart) => { - switch (start) { - case "top-left": - return "top left"; - case "top-right": - return "top right"; - case "bottom-left": - return "bottom left"; - case "bottom-right": - return "bottom right"; - case "top-center": - return "top center"; - case "bottom-center": - return "bottom center"; - case "bottom-up": - case "top-down": - case "left-right": - case "right-left": - return "center"; - } + switch (start) { + case "top-left": + return "top left"; + case "top-right": + return "top right"; + case "bottom-left": + return "bottom left"; + case "bottom-right": + return "bottom right"; + case "top-center": + return "top center"; + case "bottom-center": + return "bottom center"; + case "bottom-up": + case "top-down": + case "left-right": + case "right-left": + return "center"; + } }; export const createAnimation = ( - variant: AnimationVariant, - start: AnimationStart = "center", - blur = false, - url?: string, + variant: AnimationVariant, + start: AnimationStart = "center", + blur = false, + url?: string ): Animation => { - const svg = generateSVG(variant, start); - const transformOrigin = getTransformOrigin(start); + const svg = generateSVG(variant, start); + const transformOrigin = getTransformOrigin(start); - if (variant === "rectangle") { - const getClipPath = (direction: AnimationStart) => { - switch (direction) { - case "bottom-up": - return { - from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "top-down": - return { - from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "left-right": - return { - from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "right-left": - return { - from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "top-left": - return { - from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "top-right": - return { - from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "bottom-left": - return { - from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "bottom-right": - return { - from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - default: - return { - from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - } - }; + if (variant === "rectangle") { + const getClipPath = (direction: AnimationStart) => { + switch (direction) { + case "bottom-up": + return { + from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-down": + return { + from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "left-right": + return { + from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "right-left": + return { + from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-left": + return { + from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-right": + return { + from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "bottom-left": + return { + from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "bottom-right": + return { + from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + default: + return { + from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + } + }; - const clipPath = getClipPath(start); + const clipPath = getClipPath(start); - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); @@ -218,12 +213,12 @@ export const createAnimation = ( } } `, - }; - } - if (variant === "circle" && start == "center") { - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + }; + } + if (variant === "circle" && start == "center") { + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); @@ -268,12 +263,12 @@ export const createAnimation = ( } } `, - }; - } - if (variant === "gif") { - return { - name: `${variant}-${start}`, - css: ` + }; + } + if (variant === "gif") { + return { + name: `${variant}-${start}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-in); } @@ -302,14 +297,14 @@ export const createAnimation = ( mask-size: 2000vmax; } }`, - }; - } + }; + } - if (variant === "circle-blur") { - if (start === "center") { - return { - name: `${variant}-${start}`, - css: ` + if (variant === "circle-blur") { + if (start === "center") { + return { + name: `${variant}-${start}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-out); } @@ -334,12 +329,12 @@ export const createAnimation = ( } } `, - }; - } + }; + } - return { - name: `${variant}-${start}`, - css: ` + return { + name: `${variant}-${start}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-out); } @@ -364,41 +359,41 @@ export const createAnimation = ( } } `, - }; - } + }; + } - if (variant === "polygon") { - const getPolygonClipPaths = (position: AnimationStart) => { - switch (position) { - case "top-left": - return { - darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", - darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", - lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", - lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", - }; - case "top-right": - return { - darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)", - darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)", - lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)", - lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)", - }; - default: - return { - darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", - darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", - lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", - lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", - }; - } - }; + if (variant === "polygon") { + const getPolygonClipPaths = (position: AnimationStart) => { + switch (position) { + case "top-left": + return { + darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", + darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", + lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", + lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", + }; + case "top-right": + return { + darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)", + darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)", + lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)", + lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)", + }; + default: + return { + darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", + darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", + lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", + lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", + }; + } + }; - const clipPaths = getPolygonClipPaths(start); + const clipPaths = getPolygonClipPaths(start); - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); @@ -443,35 +438,35 @@ export const createAnimation = ( } } `, - }; - } + }; + } - // Handle circle variants with start positions using clip-path - if (variant === "circle" && start !== "center") { - const getClipPathPosition = (position: AnimationStart) => { - switch (position) { - case "top-left": - return "0% 0%"; - case "top-right": - return "100% 0%"; - case "bottom-left": - return "0% 100%"; - case "bottom-right": - return "100% 100%"; - case "top-center": - return "50% 0%"; - case "bottom-center": - return "50% 100%"; - default: - return "50% 50%"; - } - }; + // Handle circle variants with start positions using clip-path + if (variant === "circle" && start !== "center") { + const getClipPathPosition = (position: AnimationStart) => { + switch (position) { + case "top-left": + return "0% 0%"; + case "top-right": + return "100% 0%"; + case "bottom-left": + return "0% 100%"; + case "bottom-right": + return "100% 100%"; + case "top-center": + return "50% 0%"; + case "bottom-center": + return "50% 100%"; + default: + return "50% 50%"; + } + }; - const clipPosition = getClipPathPosition(start); + const clipPosition = getClipPathPosition(start); - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 1s; animation-timing-function: var(--expo-out); @@ -516,12 +511,12 @@ export const createAnimation = ( } } `, - }; - } + }; + } - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-in); } @@ -549,237 +544,229 @@ export const createAnimation = ( } } `, - }; + }; }; // /////////////////////////////////////////////////////////////////////////// // Custom hook for theme toggle functionality export const useThemeToggle = ({ - variant = "circle", - start = "center", - blur = false, - gifUrl = "", + variant = "circle", + start = "center", + blur = false, + gifUrl = "", }: { - variant?: AnimationVariant; - start?: AnimationStart; - blur?: boolean; - gifUrl?: string; + variant?: AnimationVariant; + start?: AnimationStart; + blur?: boolean; + gifUrl?: string; } = {}) => { - const { theme, setTheme, resolvedTheme } = useTheme(); + const { theme, setTheme, resolvedTheme } = useTheme(); - const [isDark, setIsDark] = useState(false); + const [isDark, setIsDark] = useState(false); - // Sync isDark state with resolved theme after hydration - useEffect(() => { - setIsDark(resolvedTheme === "dark"); - }, [resolvedTheme]); + // Sync isDark state with resolved theme after hydration + useEffect(() => { + setIsDark(resolvedTheme === "dark"); + }, [resolvedTheme]); - const styleId = "theme-transition-styles"; + const styleId = "theme-transition-styles"; - const updateStyles = useCallback((css: string) => { - if (typeof window === "undefined") return; + const updateStyles = useCallback((css: string) => { + if (typeof window === "undefined") return; - let styleElement = document.getElementById(styleId) as HTMLStyleElement; + let styleElement = document.getElementById(styleId) as HTMLStyleElement; - if (!styleElement) { - styleElement = document.createElement("style"); - styleElement.id = styleId; - document.head.appendChild(styleElement); - } + if (!styleElement) { + styleElement = document.createElement("style"); + styleElement.id = styleId; + document.head.appendChild(styleElement); + } - styleElement.textContent = css; - }, []); + styleElement.textContent = css; + }, []); - const toggleTheme = useCallback(() => { - setIsDark(!isDark); + const toggleTheme = useCallback(() => { + setIsDark(!isDark); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - if (typeof window === "undefined") return; + if (typeof window === "undefined") return; - const switchTheme = () => { - setTheme(theme === "light" ? "dark" : "light"); - }; + const switchTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); + document.startViewTransition(switchTheme); + }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); - const setCrazyLightTheme = useCallback(() => { - setIsDark(false); + const setCrazyLightTheme = useCallback(() => { + setIsDark(false); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - if (typeof window === "undefined") return; + if (typeof window === "undefined") return; - const switchTheme = () => { - setTheme("light"); - }; + const switchTheme = () => { + setTheme("light"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); - const setCrazyDarkTheme = useCallback(() => { - setIsDark(true); + const setCrazyDarkTheme = useCallback(() => { + setIsDark(true); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - if (typeof window === "undefined") return; + if (typeof window === "undefined") return; - const switchTheme = () => { - setTheme("dark"); - }; + const switchTheme = () => { + setTheme("dark"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); - const setCrazySystemTheme = useCallback(() => { - if (typeof window === "undefined") return; + const setCrazySystemTheme = useCallback(() => { + if (typeof window === "undefined") return; - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - setIsDark(prefersDark); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + setIsDark(prefersDark); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - const switchTheme = () => { - setTheme("system"); - }; + const switchTheme = () => { + setTheme("system"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); - return { - isDark, - setIsDark, - toggleTheme, - setCrazyLightTheme, - setCrazyDarkTheme, - setCrazySystemTheme, - }; + return { + isDark, + setIsDark, + toggleTheme, + setCrazyLightTheme, + setCrazyDarkTheme, + setCrazySystemTheme, + }; }; // /////////////////////////////////////////////////////////////////////////// // Theme Toggle Button Component (Sun/Moon Style) export const ThemeToggleButton = ({ - className = "", - variant = "circle", - start = "center", - blur = false, - gifUrl = "", + className = "", + variant = "circle", + start = "center", + blur = false, + gifUrl = "", }: { - className?: string; - variant?: AnimationVariant; - start?: AnimationStart; - blur?: boolean; - gifUrl?: string; + className?: string; + variant?: AnimationVariant; + start?: AnimationStart; + blur?: boolean; + gifUrl?: string; }) => { - const { isDark, toggleTheme } = useThemeToggle({ - variant, - start, - blur, - gifUrl, - }); - const clipId = useId(); - const clipPathId = `theme-toggle-clip-${clipId}`; + const { isDark, toggleTheme } = useThemeToggle({ + variant, + start, + blur, + gifUrl, + }); + const clipId = useId(); + const clipPathId = `theme-toggle-clip-${clipId}`; - return ( - - ); + return ( + + ); }; // /////////////////////////////////////////////////////////////////////////// // Backwards compatible export (alias for ThemeToggleButton with default settings) export function ThemeTogglerComponent() { - return ( - - ); + return ; } /** From 17eab845d05517d9e5e011f18c5a3d5f7695b08b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 17:17:33 +0200 Subject: [PATCH 14/32] feat: rename Mentions tab to Comments with speech bubble icon --- .../layout/ui/sidebar/InboxSidebar.tsx | 58 +++++++++++-------- surfsense_web/messages/en.json | 3 + surfsense_web/messages/zh.json | 3 + 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index b63e16317..f313dd6f9 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -4,7 +4,6 @@ import { useAtom } from "jotai"; import { AlertCircle, AlertTriangle, - AtSign, BellDot, Check, CheckCheck, @@ -15,6 +14,7 @@ import { Inbox, LayoutGrid, ListFilter, + MessageSquare, Search, X, } from "lucide-react"; @@ -134,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string { ); } -type InboxTab = "mentions" | "status"; +type InboxTab = "comments" | "status"; type InboxFilter = "all" | "unread"; // Tab-specific data source with independent pagination @@ -187,7 +187,7 @@ export function InboxSidebar({ const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); - const [activeTab, setActiveTab] = useState("mentions"); + const [activeTab, setActiveTab] = useState("comments"); const [activeFilter, setActiveFilter] = useState("all"); const [selectedConnector, setSelectedConnector] = useState(null); const [mounted, setMounted] = useState(false); @@ -234,12 +234,17 @@ export function InboxSidebar({ } }, [activeTab]); - // Get current tab's data source - each tab has independent data and pagination - const currentDataSource = activeTab === "mentions" ? mentions : status; - const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource; + // Both tabs now derive items from status (all types), so use status for pagination + const { loading, loadingMore = false, hasMore = false, loadMore } = status; - // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion, comment replies - // Filter to only show status notification types + // Comments tab: mentions and comment replies + const commentsItems = useMemo( + () => + status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"), + [status.items] + ); + + // Status tab: connector indexing, document processing, page limit exceeded, connector deletion const statusItems = useMemo( () => status.items.filter( @@ -247,8 +252,7 @@ export function InboxSidebar({ item.type === "connector_indexing" || item.type === "document_processing" || item.type === "page_limit_exceeded" || - item.type === "connector_deletion" || - item.type === "comment_reply" + item.type === "connector_deletion" ), [status.items] ); @@ -272,8 +276,8 @@ export function InboxSidebar({ })); }, [statusItems]); - // Get items for current tab - mentions use their source directly, status uses filtered items - const displayItems = activeTab === "mentions" ? mentions.items : statusItems; + // Get items for current tab + const displayItems = activeTab === "comments" ? commentsItems : statusItems; // Filter items based on filter type, connector filter, and search query const filteredItems = useMemo(() => { @@ -336,9 +340,15 @@ export function InboxSidebar({ return () => observer.disconnect(); }, [loadMore, hasMore, loadingMore, open, searchQuery]); - // Use unread counts from data sources (more accurate than client-side counting) - const unreadMentionsCount = mentions.unreadCount; - const unreadStatusCount = status.unreadCount; + // Unread counts derived from filtered items + const unreadCommentsCount = useMemo( + () => commentsItems.filter((item) => !item.read).length, + [commentsItems] + ); + const unreadStatusCount = useMemo( + () => statusItems.filter((item) => !item.read).length, + [statusItems] + ); const handleItemClick = useCallback( async (item: InboxItem) => { @@ -502,10 +512,10 @@ export function InboxSidebar({ }; const getEmptyStateMessage = () => { - if (activeTab === "mentions") { + if (activeTab === "comments") { return { - title: t("no_mentions") || "No mentions", - hint: t("no_mentions_hint") || "You'll see mentions from others here", + title: t("no_comments") || "No comments", + hint: t("no_comments_hint") || "You'll see mentions and replies here", }; } return { @@ -844,14 +854,14 @@ export function InboxSidebar({ > - - {t("mentions") || "Mentions"} + + {t("comments") || "Comments"} - {formatInboxCount(unreadMentionsCount)} + {formatInboxCount(unreadCommentsCount)} @@ -953,8 +963,8 @@ export function InboxSidebar({ ) : (
    - {activeTab === "mentions" ? ( - + {activeTab === "comments" ? ( + ) : ( )} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 75b186420..0646bdb52 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -698,10 +698,13 @@ "mark_all_read": "Mark all as read", "mark_as_read": "Mark as read", "mentions": "Mentions", + "comments": "Comments", "status": "Status", "no_results_found": "No results found", "no_mentions": "No mentions", "no_mentions_hint": "You'll see mentions from others here", + "no_comments": "No comments", + "no_comments_hint": "You'll see mentions and replies here", "no_status_updates": "No status updates", "no_status_updates_hint": "Document and connector updates will appear here", "filter": "Filter", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 81121ef3e..67077c348 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -683,10 +683,13 @@ "mark_all_read": "全部标记为已读", "mark_as_read": "标记为已读", "mentions": "提及", + "comments": "评论", "status": "状态", "no_results_found": "未找到结果", "no_mentions": "没有提及", "no_mentions_hint": "您会在这里看到他人的提及", + "no_comments": "没有评论", + "no_comments_hint": "您会在这里看到提及和回复", "no_status_updates": "没有状态更新", "no_status_updates_hint": "文档和连接器更新将显示在这里", "filter": "筛选", From 2711563e8be328b39fc58eca3157b5c8048948e6 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 11:03:31 -0500 Subject: [PATCH 15/32] Refined pricing plan descriptions by updating content limits and adding "planned" to future features. --- surfsense_web/components/pricing/pricing-section.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index c850852d8..445969ec1 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -11,7 +11,7 @@ const demoPlans = [ billingText: "Includes 30 day PRO trial", features: [ "Open source on GitHub", - "Upload and chat with up to 1,000 pages of content", + "Upload and chat with 1,000+ pages of content", "Connects with 8 popular sources, like Drive and Notion.", "Includes limited access to ChatGPT, Claude, and DeepSeek models", "Supports 100+ more LLMs, including Gemini, Llama and many more.", @@ -33,13 +33,13 @@ const demoPlans = [ billingText: "billed annually", features: [ "Everything in Free", - "Upload and chat with up to 20,000 pages of content", + "Upload and chat with up to 20,000+ pages of content", "Connects with 15+ external sources, like Slack and Airtable.", "Includes extended access to ChatGPT, Claude, and DeepSeek models", "Collaboration and commenting features", - "Centralized billing", "Shared BYOK (Bring Your Own Key)", "Team and role management", + "Planned: Centralized billing", "Priority support", ], description: "The AIknowledge base for individuals and teams", @@ -58,9 +58,9 @@ const demoPlans = [ "Connect and chat with virtually unlimited pages of content", "Limit models and/or providers", "On-prem or VPC deployment", - "Audit logs and compliance", - "SSO, OIDC & SAML", - "Role-based access control (RBAC)", + "Planned: Audit logs and compliance", + "Planned: SSO, OIDC & SAML", + "Planned: Role-based access control (RBAC)", "White-glove setup and deployment", "Monthly managed updates and maintenance", "SLA commitments", From f5aa520743e25d3b600b4af310165eaa09d99696 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:04:53 +0200 Subject: [PATCH 16/32] Remove branch picker and restrict edit/reload to last message --- .../assistant-ui/assistant-message.tsx | 22 ++++++----- .../components/assistant-ui/branch-picker.tsx | 32 ---------------- .../components/assistant-ui/user-message.tsx | 38 +++++++++++++++---- 3 files changed, 42 insertions(+), 50 deletions(-) delete mode 100644 surfsense_web/components/assistant-ui/branch-picker.tsx diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 4fd2446c3..5cdd287de 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -4,20 +4,19 @@ import { ErrorPrimitive, MessagePrimitive, useAssistantState, + useMessage, } from "@assistant-ui/react"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { addingCommentToMessageIdAtom, - clearTargetCommentIdAtom, commentsCollapsedAtom, commentsEnabledAtom, targetCommentIdAtom, } from "@/atoms/chat/current-thread.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ThinkingStepsContext, @@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
    -
    @@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => { // Target comment navigation - read target from global atom const targetCommentId = useAtomValue(targetCommentIdAtom); - const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); // Check if target comment belongs to this message (including replies) const hasTargetComment = useMemo(() => { @@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => { }; const AssistantActionBar: FC = () => { + const { isLast } = useMessage(); + return ( { - - - - - + {/* Only allow regenerating the last assistant message */} + {isLast && ( + + + + + + )} ); }; diff --git a/surfsense_web/components/assistant-ui/branch-picker.tsx b/surfsense_web/components/assistant-ui/branch-picker.tsx deleted file mode 100644 index ee4addd2a..000000000 --- a/surfsense_web/components/assistant-ui/branch-picker.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { BranchPickerPrimitive } from "@assistant-ui/react"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import type { FC } from "react"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { cn } from "@/lib/utils"; - -export const BranchPicker: FC = ({ className, ...rest }) => { - return ( - - - - - - - - / - - - - - - - - ); -}; diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 896b8c748..1ae8aef3c 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -4,7 +4,6 @@ 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 { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; interface AuthorMetadata { @@ -95,24 +94,47 @@ export const UserMessage: FC = () => { )} - - ); }; const UserActionBar: FC = () => { + const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + + // Get current message ID + const currentMessageId = useAssistantState(({ message }) => message?.id); + + // Find the last user message ID in the thread (computed once, memoized by selector) + const lastUserMessageId = useAssistantState(({ thread }) => { + const messages = thread.messages; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + return messages[i].id; + } + } + return null; + }); + + // Simple comparison - no iteration needed per message + const isLastUserMessage = currentMessageId === lastUserMessageId; + + // Show edit button only on the last user message and when thread is not running + const canEdit = isLastUserMessage && !isThreadRunning; + return ( - - - - - + {/* Only allow editing the last user message */} + {canEdit && ( + + + + + + )} ); }; From fb371d09f51120498de77d7e27940a3b9cf3fa32 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:26:38 +0200 Subject: [PATCH 17/32] Add globe indicator for chats with public links --- .../components/new-chat/chat-share-button.tsx | 237 ++++++++++-------- 1 file changed, 136 insertions(+), 101 deletions(-) diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index fa05f44c1..aae9f18c4 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -1,8 +1,9 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { Globe, User, Users } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; @@ -11,6 +12,7 @@ import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapsh import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; import { type ChatVisibility, type ThreadRecord, @@ -46,6 +48,8 @@ const visibilityOptions: { export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { const queryClient = useQueryClient(); + const router = useRouter(); + const params = useParams(); const [open, setOpen] = useState(false); // Use Jotai atom for visibility (single source of truth) @@ -65,6 +69,16 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS return access.permissions?.includes("public_sharing:create") ?? false; }, [access]); + // Query to check if thread has public snapshots + const { data: snapshotsData } = useQuery({ + queryKey: ["thread-snapshots", thread?.id], + queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }), + enabled: !!thread?.id, + staleTime: 30000, // Cache for 30 seconds + }); + const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0; + const snapshotCount = snapshotsData?.snapshots?.length ?? 0; + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; @@ -106,11 +120,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS try { await createSnapshot({ thread_id: thread.id }); + // Refetch snapshots to show the globe indicator + await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] }); setOpen(false); } catch (error) { console.error("Failed to create public link:", error); } - }, [thread, createSnapshot]); + }, [thread, createSnapshot, queryClient]); // Don't show if no thread (new chat that hasn't been created yet) if (!thread) { @@ -121,112 +137,131 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared"; return ( - - - - - - - - Share settings - - - e.preventDefault()} - > -
    - {/* Visibility Options */} - {visibilityOptions.map((option) => { - const isSelected = currentVisibility === option.value; - const Icon = option.icon; - - return ( - + + + Share settings + + + e.preventDefault()} + > +
    + {/* Visibility Options */} + {visibilityOptions.map((option) => { + const isSelected = currentVisibility === option.value; + const Icon = option.icon; + + return ( +
    -
    -
    - - {option.label} - + > +
    -

    - {option.description} -

    -
    - - ); - })} - - {canCreatePublicLink && ( - <> - {/* Divider */} -
    - - {/* Public Link Option */} - - - )} -
    -
    - + + ); + })} + + {canCreatePublicLink && ( + <> + {/* Divider */} +
    + + {/* Public Link Option */} + + + )} +
    + + + + {/* Globe indicator when public snapshots exist - clicks to settings */} + {hasPublicSnapshots && ( + + + + + + {snapshotCount === 1 + ? "This chat has a public link - Click to manage" + : `This chat has ${snapshotCount} public links - Click to manage`} + + + )} +
    ); } From 83aa8ef6892507193976c6246dee4f7fbcbff167 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 11:30:30 -0500 Subject: [PATCH 18/32] Added build version for cloud, local and Docker installs --- .../user/settings/components/UserSettingsSidebar.tsx | 6 ++++++ surfsense_web/lib/env-config.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx index b7040b4e3..3424113a9 100644 --- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx +++ b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { APP_VERSION } from "@/lib/env-config"; import { cn } from "@/lib/utils"; export interface SettingsNavItem { @@ -148,6 +149,11 @@ export function UserSettingsSidebar({ ); })} + + {/* Version display */} +
    +

    v{APP_VERSION}

    +
    ); diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 2f9e92357..e36aff10a 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -9,6 +9,8 @@ * as it may prevent the sed replacement from working correctly. */ +import packageJson from "../package.json"; + // Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; @@ -28,6 +30,10 @@ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"; // Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__ export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"; +// App version - defaults to package.json version +// Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version +export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version; + // Helper to check if local auth is enabled export const isLocalAuth = () => AUTH_TYPE === "LOCAL"; From ab3d99d9e07ebd7aa47cccf929df4d8ee5ca849c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:46:12 +0200 Subject: [PATCH 19/32] Use RBAC for listing thread snapshots --- .../app/services/public_chat_service.py | 13 ++++++++----- .../components/new-chat/chat-share-button.tsx | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 2125dd8ce..4da316240 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -366,11 +366,14 @@ async def list_snapshots_for_thread( if not thread: raise HTTPException(status_code=404, detail="Thread not found") - if thread.created_by_id != user.id: - raise HTTPException( - status_code=403, - detail="Only the creator can view snapshots", - ) + # Check permission to view public share links + await check_permission( + session, + user, + thread.search_space_id, + Permission.PUBLIC_SHARING_VIEW.value, + "You don't have permission to view public share links", + ) result = await session.execute( select(PublicChatSnapshot) diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index aae9f18c4..2e04fa3ba 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -257,8 +257,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS {snapshotCount === 1 - ? "This chat has a public link - Click to manage" - : `This chat has ${snapshotCount} public links - Click to manage`} + ? "This chat has a public link" + : `This chat has ${snapshotCount} public links`} )} From 1cf7205a81575f9cdc05c25cc26b653743418b2c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:54:59 +0200 Subject: [PATCH 20/32] Add clipboard utility with fallback and show selectable URLs --- .../public-chat-snapshot-row.tsx | 7 +++ surfsense_web/hooks/use-api-key.ts | 60 +++---------------- surfsense_web/lib/utils.ts | 41 +++++++++++++ 3 files changed, 57 insertions(+), 51 deletions(-) diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx index 696d32466..5f0048100 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx @@ -38,6 +38,13 @@ export function PublicChatSnapshotRow({ {snapshot.message_count} + (e.target as HTMLInputElement).select()} + />