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 */}
+
+
{/* Delete Search Space Dialog */}