From ad5a49c2c6e675b38c21d510506f0ed5e16e6cd4 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 01:55:45 +0530 Subject: [PATCH 01/24] refactor: enhance auto-focus behavior in Composer component - Implemented a mechanism to auto-focus the editor immediately after the first message is sent, improving user experience by allowing quick follow-up queries. - Introduced a reference to track the previous state of the thread to ensure focus only occurs during the transition from empty to non-empty. --- surfsense_web/components/assistant-ui/thread.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 90d4e62a3..fb96e4d7a 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -409,6 +409,7 @@ const Composer: FC = () => { const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); + const prevIsThreadEmptyRef = useRef(true); // Check if thread is empty (new chat) const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); @@ -428,11 +429,20 @@ const Composer: FC = () => { } }, [isThreadEmpty]); - // Reset auto-focus flag when thread becomes non-empty (user sent a message) + // Auto-focus editor immediately after first message is sent (when thread transitions from empty to non-empty) + // This allows the user to start typing the second query right away useEffect(() => { - if (!isThreadEmpty) { - hasAutoFocusedRef.current = false; + // Only focus when transitioning from empty to non-empty (first message sent) + if (prevIsThreadEmptyRef.current && !isThreadEmpty && editorRef.current) { + // Small delay to ensure the editor is ready after the message is sent + const timeoutId = setTimeout(() => { + editorRef.current?.focus(); + }, 50); + prevIsThreadEmptyRef.current = isThreadEmpty; + return () => clearTimeout(timeoutId); } + // Update the ref to track the previous state + prevIsThreadEmptyRef.current = isThreadEmpty; }, [isThreadEmpty]); // Sync mentioned document IDs to atom for use in chat request From d9df63f57eb10ea45e33347366d219b989c8c673 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 02:37:20 +0530 Subject: [PATCH 02/24] refactor: enhance web crawling functionality with Firecrawl integration - Updated WebCrawlerConnector to prioritize Firecrawl API for crawling if an API key is provided, falling back to Chromium if Firecrawl fails. - Improved error handling to log failures from both Firecrawl and Chromium. - Enhanced link preview tool to use a random User-Agent for better compatibility with web servers. - Passed Firecrawl API key to the stream_new_chat function for improved configuration management. --- .../app/agents/new_chat/tools/link_preview.py | 7 ++- .../app/connectors/webcrawler_connector.py | 49 ++++++++++--------- .../app/tasks/chat/stream_new_chat.py | 11 +++++ 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/tools/link_preview.py b/surfsense_backend/app/agents/new_chat/tools/link_preview.py index 17e89345e..13f8a1f1a 100644 --- a/surfsense_backend/app/agents/new_chat/tools/link_preview.py +++ b/surfsense_backend/app/agents/new_chat/tools/link_preview.py @@ -280,15 +280,18 @@ def create_link_preview_tool(): url = f"https://{url}" try: + # Generate a random User-Agent to avoid bot detection + ua = UserAgent() + user_agent = ua.random + # Use a browser-like User-Agent to fetch Open Graph metadata. - # This is the same approach used by Slack, Discord, Twitter, etc. for link previews. # We're only fetching publicly available metadata (title, description, thumbnail) # that websites intentionally expose via OG tags for link preview purposes. async with httpx.AsyncClient( timeout=10.0, follow_redirects=True, headers={ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", diff --git a/surfsense_backend/app/connectors/webcrawler_connector.py b/surfsense_backend/app/connectors/webcrawler_connector.py index 3fc61f0b5..411f99a51 100644 --- a/surfsense_backend/app/connectors/webcrawler_connector.py +++ b/surfsense_backend/app/connectors/webcrawler_connector.py @@ -25,7 +25,9 @@ class WebCrawlerConnector: Initialize the WebCrawlerConnector class. Args: - firecrawl_api_key: Firecrawl API key (optional, will use AsyncChromiumLoader if not provided) + firecrawl_api_key: Firecrawl API key (optional). If provided, Firecrawl will be tried first + and Chromium will be used as fallback if Firecrawl fails. If not provided, + Chromium will be used directly. """ self.firecrawl_api_key = firecrawl_api_key self.use_firecrawl = bool(firecrawl_api_key) @@ -46,6 +48,9 @@ class WebCrawlerConnector: """ Crawl a single URL and extract its content. + If Firecrawl API key is provided, tries Firecrawl first and falls back to Chromium + if Firecrawl fails. If no Firecrawl API key is provided, uses Chromium directly. + Args: url: URL to crawl formats: List of formats to extract (e.g., ["markdown", "html"]) - only for Firecrawl @@ -56,19 +61,32 @@ class WebCrawlerConnector: - content: Extracted content (markdown or HTML) - metadata: Page metadata (title, description, etc.) - source: Original URL - - crawler_type: Type of crawler used + - crawler_type: Type of crawler used ("firecrawl" or "chromium") """ try: # Validate URL if not validators.url(url): return None, f"Invalid URL: {url}" + # Try Firecrawl first if API key is provided if self.use_firecrawl: - result = await self._crawl_with_firecrawl(url, formats) + try: + logger.info(f"[webcrawler] Using Firecrawl for: {url}") + result = await self._crawl_with_firecrawl(url, formats) + return result, None + except Exception as firecrawl_error: + # Firecrawl failed, fallback to Chromium + logger.warning(f"[webcrawler] Firecrawl failed, falling back to Chromium+Trafilatura for: {url}") + try: + result = await self._crawl_with_chromium(url) + return result, None + except Exception as chromium_error: + return None, f"Both Firecrawl and Chromium failed. Firecrawl error: {firecrawl_error!s}, Chromium error: {chromium_error!s}" else: + # No Firecrawl API key, use Chromium directly + logger.info(f"[webcrawler] Using Chromium+Trafilatura for: {url}") result = await self._crawl_with_chromium(url) - - return result, None + return result, None except Exception as e: return None, f"Error crawling URL {url}: {e!s}" @@ -162,10 +180,6 @@ class WebCrawlerConnector: trafilatura_metadata = None try: - logger.info( - f"Attempting to extract main content from {url} using Trafilatura" - ) - # Extract main content as markdown extracted_content = trafilatura.extract( raw_html, @@ -179,23 +193,10 @@ class WebCrawlerConnector: # Extract metadata using Trafilatura trafilatura_metadata = trafilatura.extract_metadata(raw_html) - if extracted_content and len(extracted_content.strip()) > 0: - logger.info( - f"Successfully extracted main content from {url} using Trafilatura " - f"({len(extracted_content)} chars vs {len(raw_html)} chars raw HTML)" - ) - else: - logger.warning( - f"Trafilatura extraction returned empty content for {url}, " - "falling back to raw HTML" - ) + if not extracted_content or len(extracted_content.strip()) == 0: extracted_content = None - except Exception as e: - logger.warning( - f"Trafilatura extraction failed for {url}: {e}. " - "Falling back to raw HTML" - ) + except Exception: extracted_content = None # Build metadata, preferring Trafilatura metadata when available diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index aff6fa32b..8b326e1d1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -146,6 +146,16 @@ async def stream_new_chat( # Create connector service connector_service = ConnectorService(session, search_space_id=search_space_id) + # Get Firecrawl API key from webcrawler connector if configured + from app.db import SearchSourceConnectorType + + firecrawl_api_key = None + webcrawler_connector = await connector_service.get_connector_by_type( + SearchSourceConnectorType.WEBCRAWLER_CONNECTOR, search_space_id + ) + if webcrawler_connector and webcrawler_connector.config: + firecrawl_api_key = webcrawler_connector.config.get("FIRECRAWL_API_KEY") + # Get the PostgreSQL checkpointer for persistent conversation memory checkpointer = await get_checkpointer() @@ -157,6 +167,7 @@ async def stream_new_chat( connector_service=connector_service, checkpointer=checkpointer, agent_config=agent_config, # Pass prompt configuration + firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured ) # Build input with message history from frontend From 287fc236ad1b04a6015c6f8497820fcc8aadafb0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 03:11:49 +0530 Subject: [PATCH 03/24] refactor: enhance chat functionality with query invalidation and navigation improvements - Integrated useQueryClient from React Query to manage query invalidation for threads, ensuring real-time updates in the sidebar when a new chat is created or a thread is deleted. - Updated the AllChatsSidebar to close the sidebar and navigate to the new chat page if the currently open chat is deleted. - Improved error handling and user experience during thread deletion by ensuring the sidebar closes smoothly before redirection. --- .../new-chat/[[...chat_id]]/page.tsx | 15 ++++++++++++- .../components/sidebar/AppSidebarProvider.tsx | 4 +++- .../components/sidebar/all-chats-sidebar.tsx | 21 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 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 f288bfa59..722973067 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 @@ -6,6 +6,7 @@ import { type ThreadMessageLike, useExternalStoreRuntime, } from "@assistant-ui/react"; +import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -140,6 +141,7 @@ interface ThinkingStepData { export default function NewChatPage() { const params = useParams(); + const queryClient = useQueryClient(); const [isInitializing, setIsInitializing] = useState(true); const [threadId, setThreadId] = useState(null); const [messages, setMessages] = useState([]); @@ -300,11 +302,13 @@ export default function NewChatPage() { // Lazy thread creation: create thread on first message if it doesn't exist let currentThreadId = threadId; + let isNewThread = false; if (!currentThreadId) { try { const newThread = await createThread(searchSpaceId, "New Chat"); currentThreadId = newThread.id; setThreadId(currentThreadId); + isNewThread = true; // Update URL silently using browser API (not router.replace) to avoid // interrupting the ongoing fetch/streaming with React navigation window.history.replaceState( @@ -362,7 +366,15 @@ export default function NewChatPage() { appendMessage(currentThreadId, { role: "user", content: persistContent, - }).catch((err) => console.error("Failed to persist user message:", err)); + }) + .then(() => { + // For new threads, the backend updates the title from the first user message + // Invalidate threads query so sidebar shows the updated title in real-time + if (isNewThread) { + queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] }); + } + }) + .catch((err) => console.error("Failed to persist user message:", err)); // Start streaming response setIsRunning(true); @@ -692,6 +704,7 @@ export default function NewChatPage() { setMentionedDocumentIds, setMentionedDocuments, setMessageDocumentsMap, + queryClient, ] ); diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 5e7f08c4d..0ee0bb230 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -149,6 +149,8 @@ export function AppSidebarProvider({ await deleteThread(threadToDelete.id); // Invalidate threads query to refresh the list queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + // Navigate to new-chat after successful deletion + router.push(`/dashboard/${searchSpaceId}/new-chat`); } catch (error) { console.error("Error deleting thread:", error); } finally { @@ -156,7 +158,7 @@ export function AppSidebarProvider({ setShowDeleteDialog(false); setThreadToDelete(null); } - }, [threadToDelete, queryClient, searchSpaceId]); + }, [threadToDelete, queryClient, searchSpaceId, router]); // Handle delete note with confirmation const handleDeleteNote = useCallback(async () => { diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx index 9076715a3..a820a90c8 100644 --- a/surfsense_web/components/sidebar/all-chats-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-chats-sidebar.tsx @@ -13,7 +13,7 @@ import { X, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; @@ -47,7 +47,15 @@ interface AllChatsSidebarProps { export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const params = useParams(); const queryClient = useQueryClient(); + + // Get the current chat ID from URL to check if user is deleting the currently open chat + const currentChatId = Array.isArray(params.chat_id) + ? Number(params.chat_id[0]) + : params.chat_id + ? Number(params.chat_id) + : null; const [deletingThreadId, setDeletingThreadId] = useState(null); const [archivingThreadId, setArchivingThreadId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -126,6 +134,15 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + + // If the deleted chat is currently open, close sidebar first then redirect + if (currentChatId === threadId) { + onOpenChange(false); + // Wait for sidebar close animation to complete before navigating + setTimeout(() => { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + }, 250); + } } catch (error) { console.error("Error deleting thread:", error); toast.error(t("error_deleting_chat") || "Failed to delete chat"); @@ -133,7 +150,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS setDeletingThreadId(null); } }, - [queryClient, searchSpaceId, t] + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] ); // Handle thread archive/unarchive From 1dd740bb23a943a56fa9e93a14e4058d65756aab Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:38:32 +0530 Subject: [PATCH 04/24] refactor: enhance chat functionality with new tools and attachment handling - Updated system prompt to clarify usage of the display_image tool, emphasizing valid URL requirements and prohibiting user-uploaded image displays. - Introduced write_todos tool for creating and updating planning lists, with detailed usage instructions and examples. - Enhanced message handling to persist attachments and mentioned documents, ensuring they survive page reloads. - Improved UI components to integrate the new write_todos tool and ensure consistent user experience across chat interactions. --- .../app/agents/new_chat/system_prompt.py | 36 ++++-- .../new-chat/[[...chat_id]]/page.tsx | 104 +++++++++++++++--- .../components/assistant-ui/thread.tsx | 2 +- 3 files changed, 112 insertions(+), 30 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 61a8fbdd6..3c5ab55a0 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -64,18 +64,23 @@ You have access to the following tools: - The preview card will automatically be displayed in the chat. 4. display_image: Display an image in the chat with metadata. - - Use this tool when you want to show an image from a URL to the user. + - Use this tool ONLY when you have a valid public HTTP/HTTPS image URL to show. - This displays the image with an optional title, description, and source attribution. - - Common use cases: - * Showing an image from a URL mentioned in the conversation - * Displaying a diagram, chart, or illustration you're referencing - * Showing visual examples when explaining concepts - - IMPORTANT: Do NOT use this tool for user-uploaded image attachments! - * User attachments are already visible in the chat UI - the user can see them - * This tool requires a valid HTTP/HTTPS URL, not a local file path - * When a user uploads an image, just analyze it and respond - don't try to display it again + - Valid use cases: + * Showing an image from a URL the user explicitly mentioned in their message + * Displaying images found in scraped webpage content (from scrape_webpage tool) + * Showing a publicly accessible diagram or chart from a known URL + + CRITICAL - NEVER USE THIS TOOL FOR USER-UPLOADED ATTACHMENTS: + When a user uploads/attaches an image file to their message: + * The image is ALREADY VISIBLE in the chat UI as a thumbnail on their message + * You do NOT have a URL for their uploaded image - only extracted text/description + * Calling display_image will FAIL and show "Image not available" error + * Simply analyze the image content and respond with your analysis - DO NOT try to display it + * The user can already see their own uploaded image - they don't need you to show it again + - Args: - - src: The URL of the image to display (must be a valid HTTP/HTTPS image URL, not a local path) + - src: The URL of the image (MUST be a valid public HTTP/HTTPS URL that you know exists) - alt: Alternative text describing the image (for accessibility) - title: Optional title to display below the image - description: Optional description providing context about the image @@ -134,8 +139,15 @@ You have access to the following tools: - User: "Show me this image: https://example.com/image.png" - Call: `display_image(src="https://example.com/image.png", alt="User shared image")` -- User: "Can you display a diagram of a neural network?" - - Call: `display_image(src="https://example.com/neural-network.png", alt="Neural network diagram", title="Neural Network Architecture", description="A visual representation of a neural network with input, hidden, and output layers")` +- User uploads an image file and asks: "What is this image about?" + - DO NOT call display_image! The user's uploaded image is already visible in the chat. + - Simply analyze the image content (which you receive as extracted text/description) and respond. + - WRONG: `display_image(src="...", ...)` - This will fail with "Image not available" + - CORRECT: Just provide your analysis directly: "Based on the image you shared, this appears to be..." + +- User uploads a screenshot and asks: "Can you explain what's in this image?" + - DO NOT call display_image! Just analyze and respond directly. + - The user can already see their screenshot - they don't need you to display it again. - User: "Read this article and summarize it for me: https://example.com/blog/ai-trends" - Call: `scrape_webpage(url="https://example.com/blog/ai-trends")` 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 722973067..20b71dd63 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 @@ -86,9 +86,44 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { return []; } +/** + * Zod schema for persisted attachment info + */ +const PersistedAttachmentSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + imageDataUrl: z.string().optional(), + extractedContent: z.string().optional(), +}); + +const AttachmentsPartSchema = z.object({ + type: z.literal("attachments"), + items: z.array(PersistedAttachmentSchema), +}); + +type PersistedAttachment = z.infer; + +/** + * Extract persisted attachments from message content (type-safe with Zod) + */ +function extractPersistedAttachments(content: unknown): PersistedAttachment[] { + if (!Array.isArray(content)) return []; + + for (const part of content) { + const result = AttachmentsPartSchema.safeParse(part); + if (result.success) { + return result.data.items; + } + } + + return []; +} + /** * Convert backend message to assistant-ui ThreadMessageLike format * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps + * Restores attachments for user messages from persisted data */ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { let content: ThreadMessageLike["content"]; @@ -100,8 +135,8 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { const filteredContent = msg.content.filter((part: unknown) => { if (typeof part !== "object" || part === null || !("type" in part)) return true; const partType = (part as { type: string }).type; - // Filter out thinking-steps and mentioned-documents - return partType !== "thinking-steps" && partType !== "mentioned-documents"; + // Filter out thinking-steps, mentioned-documents, and attachments + return partType !== "thinking-steps" && partType !== "mentioned-documents" && partType !== "attachments"; }); content = filteredContent.length > 0 @@ -111,11 +146,30 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { content = [{ type: "text", text: String(msg.content) }]; } + // Restore attachments for user messages + let attachments: ThreadMessageLike["attachments"]; + if (msg.role === "user") { + const persistedAttachments = extractPersistedAttachments(msg.content); + if (persistedAttachments.length > 0) { + attachments = persistedAttachments.map((att) => ({ + id: att.id, + name: att.name, + type: att.type as "document" | "image" | "file", + status: { type: "complete" as const }, + content: [], + // Custom fields for our ChatAttachment interface + imageDataUrl: att.imageDataUrl, + extractedContent: att.extractedContent, + })); + } + } + return { id: `msg-${msg.id}`, role: msg.role, content, createdAt: new Date(msg.created_at), + attachments, }; } @@ -348,21 +402,37 @@ export default function NewChatPage() { })); } - // Persist user message with mentioned documents (don't await, fire and forget) - const persistContent = - mentionedDocuments.length > 0 - ? [ - ...message.content, - { - type: "mentioned-documents", - documents: mentionedDocuments.map((doc) => ({ - id: doc.id, - title: doc.title, - document_type: doc.document_type, - })), - }, - ] - : message.content; + // Persist user message with mentioned documents and attachments (don't await, fire and forget) + const persistContent: unknown[] = [...message.content]; + + // Add mentioned documents for persistence + if (mentionedDocuments.length > 0) { + persistContent.push({ + type: "mentioned-documents", + documents: mentionedDocuments.map((doc) => ({ + id: doc.id, + title: doc.title, + document_type: doc.document_type, + })), + }); + } + + // Add attachments for persistence (so they survive page reload) + if (message.attachments && message.attachments.length > 0) { + persistContent.push({ + type: "attachments", + items: message.attachments.map((att) => ({ + id: att.id, + name: att.name, + type: att.type, + // Include imageDataUrl for images so they can be displayed after reload + imageDataUrl: (att as { imageDataUrl?: string }).imageDataUrl, + // Include extractedContent for context (already extracted, no re-processing needed) + extractedContent: (att as { extractedContent?: string }).extractedContent, + })), + }); + } + appendMessage(currentThreadId, { role: "user", content: persistContent, diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index fb96e4d7a..fa633a55a 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -571,7 +571,7 @@ const Composer: FC = () => {
Date: Fri, 26 Dec 2025 14:37:23 +0530 Subject: [PATCH 05/24] refactor: introduce write_todos tool for enhanced task management - Added a new write_todos tool to facilitate the creation and management of planning lists within the chat interface. - Updated system prompt with detailed instructions on using the write_todos tool, including usage patterns and restrictions. - Enhanced the chat message handling to support the new tool, ensuring proper integration and user experience. - Implemented UI components for displaying and interacting with the planning lists, including progress tracking and status indicators. --- .../app/agents/new_chat/system_prompt.py | 75 +++++ .../app/agents/new_chat/tools/registry.py | 8 + .../app/agents/new_chat/tools/write_todos.py | 94 +++++++ .../app/tasks/chat/stream_new_chat.py | 41 +++ .../new-chat/[[...chat_id]]/page.tsx | 20 +- surfsense_web/atoms/chat/plan-state.atom.ts | 234 ++++++++++++++++ surfsense_web/components/tool-ui/index.ts | 14 + .../components/tool-ui/plan/index.tsx | 53 ++++ .../components/tool-ui/plan/plan.tsx | 265 ++++++++++++++++++ .../components/tool-ui/plan/schema.ts | 67 +++++ .../tool-ui/shared/action-buttons.tsx | 42 +++ .../components/tool-ui/shared/index.ts | 3 + .../components/tool-ui/shared/schema.ts | 24 ++ .../components/tool-ui/write-todos.tsx | 199 +++++++++++++ 14 files changed, 1138 insertions(+), 1 deletion(-) create mode 100644 surfsense_backend/app/agents/new_chat/tools/write_todos.py create mode 100644 surfsense_web/atoms/chat/plan-state.atom.ts create mode 100644 surfsense_web/components/tool-ui/plan/index.tsx create mode 100644 surfsense_web/components/tool-ui/plan/plan.tsx create mode 100644 surfsense_web/components/tool-ui/plan/schema.ts create mode 100644 surfsense_web/components/tool-ui/shared/action-buttons.tsx create mode 100644 surfsense_web/components/tool-ui/shared/index.ts create mode 100644 surfsense_web/components/tool-ui/shared/schema.ts create mode 100644 surfsense_web/components/tool-ui/write-todos.tsx diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 3c5ab55a0..d85a89db7 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -109,6 +109,65 @@ You have access to the following tools: * This makes your response more visual and engaging. * Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content. * Don't show every image - just the most relevant 1-3 images that enhance understanding. + +6. write_todos: Create and update a planning/todo list to break down complex tasks. + - Use this tool when you need to plan your approach to a complex task. + - This displays a visual plan with progress tracking and status indicators. + + - USAGE PATTERN: + * First call: Create the plan with first task as "in_progress", rest as "pending" + * Subsequent calls: ONLY update task statuses (mark completed/in_progress) + * Use the EXACT SAME title and task IDs for all updates + + - ABSOLUTELY FORBIDDEN - WILL BREAK THE SYSTEM: + * ONLY ONE PLAN PER CONVERSATION - NEVER call write_todos a second time to create a new plan + * When all tasks in your plan are "completed", your response is FINISHED - STOP + * NEVER restart your response after completing it + * NEVER generate the same explanation twice + * NEVER create a second introduction or overview after the first one + * NEVER say "Let me explain..." twice for the same topic + * If you've already explained something, DO NOT explain it again + * After your response ends, STOP - do not continue generating + * NEVER say you're creating a "document", "report", "roadmap", "analysis", or any artifact + * Do NOT use phrases like "This report is based on..." or "Based on my research..." + * Just answer the question directly - do not roleplay producing a deliverable + + - CORRECT BEHAVIOR: + * Call write_todos to update statuses as you progress + * Each section of your response appears EXACTLY ONCE + * When you finish explaining all tasks, your response is COMPLETE + * Do NOT generate additional content after concluding + + - CONTENT QUALITY: + * Provide thorough, detailed explanations for each task + * The restriction is on DUPLICATING content, not on depth or detail + * Each task deserves a complete, comprehensive explanation + * Be as detailed as needed - just don't repeat yourself + + - When to use: + * Breaking down a complex multi-step task (3-5 tasks recommended) + * Showing the user what steps you'll take to solve their problem + * Creating an implementation roadmap + + - Args: + - todos: List of todo items, each with: + * id: Unique identifier (KEEP SAME IDs across updates) + * content: Description of the task (KEEP SAME content across updates) + * status: "pending", "in_progress", or "completed" + - title: Title for the plan (MUST BE IDENTICAL across all updates) + - description: Optional context description + + - Returns: A visual plan card with progress bar and status indicators + + - CORRECT PATTERN: + 1. Create plan with task 1 as "in_progress" + 2. Explain task 1 content in detail + 3. Update plan: task 1 "completed", task 2 "in_progress" + 4. Explain task 2 content (NEW content, not repeating task 1) + 5. Continue until all tasks are "completed" + 6. When all tasks are "completed", your response is FINISHED + 7. STOP IMMEDIATELY - do NOT create another plan or continue generating + 8. ONE PLAN ONLY - never call write_todos again after completing all tasks - User: "Fetch all my notes and what's in them?" @@ -166,6 +225,22 @@ You have access to the following tools: - Then, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`: - Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")` - Then provide your explanation, referencing the displayed image + +- User: "Help me implement a user authentication system" + - Step 1: Create plan with task 1 in_progress: + `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "in_progress"}, {"id": "2", "content": "Set up password hashing", "status": "pending"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` + - Step 2: Provide DETAILED explanation of database schema design + - Step 3: Update plan (task 1 done, task 2 in_progress): + `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "completed"}, {"id": "2", "content": "Set up password hashing", "status": "in_progress"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` + - Step 4: Provide DETAILED explanation of password hashing (NEW content only) + - Step 5: Update plan, explain endpoints in detail + - Step 6: Mark all complete, END response - DO NOT restart or regenerate + - FORBIDDEN: Do not go back and explain schema again after step 2 + +- User: "How should I approach refactoring this large codebase?" + - Create plan, explain each step with thorough detail, update statuses as you go + - Each explanation is comprehensive but appears ONLY ONCE + - When finished with all tasks, STOP - do not continue generating """ diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 3b0c2ddac..5c746d726 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -48,6 +48,7 @@ from .knowledge_base import create_search_knowledge_base_tool from .link_preview import create_link_preview_tool from .podcast import create_generate_podcast_tool from .scrape_webpage import create_scrape_webpage_tool +from .write_todos import create_write_todos_tool # ============================================================================= # Tool Definition @@ -125,6 +126,13 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ ), requires=[], # firecrawl_api_key is optional ), + # Planning/Todo tool - creates visual todo lists + ToolDefinition( + name="write_todos", + description="Create a planning/todo list to break down complex tasks", + factory=lambda deps: create_write_todos_tool(), + requires=[], + ), # ========================================================================= # ADD YOUR CUSTOM TOOLS BELOW # ========================================================================= diff --git a/surfsense_backend/app/agents/new_chat/tools/write_todos.py b/surfsense_backend/app/agents/new_chat/tools/write_todos.py new file mode 100644 index 000000000..95b5cb155 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/write_todos.py @@ -0,0 +1,94 @@ +""" +Write todos tool for the SurfSense agent. + +This module provides a tool for creating and displaying a planning/todo list +in the chat UI. It helps the agent break down complex tasks into steps. +""" + +from typing import Any, Literal + +from langchain_core.tools import tool + + +def create_write_todos_tool(): + """ + Factory function to create the write_todos tool. + + Returns: + A configured tool function for writing todos/plans. + """ + + @tool + async def write_todos( + todos: list[dict[str, Any]], + title: str = "Planning Approach", + description: str | None = None, + ) -> dict[str, Any]: + """ + Create a planning/todo list to break down a complex task. + + Use this tool when you need to plan your approach to a complex task + or show the user a step-by-step breakdown of what you'll do. + + This displays a visual plan with: + - Progress tracking (X of Y complete) + - Status indicators (pending, in progress, completed, cancelled) + - Expandable details for each step + + Args: + todos: List of todo items. Each item should have: + - id: Unique identifier for the todo + - content: Description of the task + - status: One of "pending", "in_progress", "completed", "cancelled" + title: Title for the plan (default: "Planning Approach") + description: Optional description providing context + + Returns: + A dictionary containing the plan data for the UI to render. + + Example: + write_todos( + title="Implementation Plan", + description="Steps to add the new feature", + todos=[ + {"id": "1", "content": "Analyze requirements", "status": "completed"}, + {"id": "2", "content": "Design solution", "status": "in_progress"}, + {"id": "3", "content": "Write code", "status": "pending"}, + {"id": "4", "content": "Add tests", "status": "pending"}, + ] + ) + """ + # Generate a unique plan ID + import uuid + + plan_id = f"plan-{uuid.uuid4().hex[:8]}" + + # Transform todos to the expected format for the UI + formatted_todos = [] + for i, todo in enumerate(todos): + todo_id = todo.get("id", f"todo-{i}") + content = todo.get("content", "") + status = todo.get("status", "pending") + + # Validate status + valid_statuses = ["pending", "in_progress", "completed", "cancelled"] + if status not in valid_statuses: + status = "pending" + + formatted_todos.append( + { + "id": todo_id, + "label": content, + "status": status, + } + ) + + return { + "id": plan_id, + "title": title, + "description": description, + "todos": formatted_todos, + } + + return write_todos + diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 8b326e1d1..5bb33e399 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -718,6 +718,25 @@ async def stream_new_chat( status="completed", items=completed_items, ) + elif tool_name == "write_todos": + # Build completion items for planning + if isinstance(tool_output, dict): + plan_title = tool_output.get("title", "Plan") + todos = tool_output.get("todos", []) + todo_count = len(todos) if isinstance(todos, list) else 0 + completed_items = [ + *last_active_step_items, + f"Plan: {plan_title[:50]}{'...' if len(plan_title) > 50 else ''}", + f"Tasks: {todo_count} steps defined", + ] + else: + completed_items = [*last_active_step_items, "Plan created"] + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title="Creating plan", + status="completed", + items=completed_items, + ) else: yield streaming_service.format_thinking_step( step_id=original_step_id, @@ -854,6 +873,28 @@ async def stream_new_chat( yield streaming_service.format_terminal_info( "Knowledge base search completed", "success" ) + elif tool_name == "write_todos": + # Stream the full write_todos result so frontend can render the Plan component + yield streaming_service.format_tool_output_available( + tool_call_id, + tool_output + if isinstance(tool_output, dict) + else {"result": tool_output}, + ) + # Send terminal message with plan info + if isinstance(tool_output, dict): + title = tool_output.get("title", "Plan") + todos = tool_output.get("todos", []) + todo_count = len(todos) if isinstance(todos, list) else 0 + yield streaming_service.format_terminal_info( + f"Plan created: {title} ({todo_count} tasks)", + "success", + ) + else: + yield streaming_service.format_terminal_info( + "Plan created", + "success", + ) else: # Default handling for other tools yield streaming_service.format_tool_output_available( 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 20b71dd63..df092a630 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 @@ -18,6 +18,11 @@ import { mentionedDocumentsAtom, messageDocumentsMapAtom, } from "@/atoms/chat/mentioned-documents.atom"; +import { + clearPlanOwnerRegistry, + extractWriteTodosFromContent, + hydratePlanStateAtom, +} from "@/atoms/chat/plan-state.atom"; import { Thread } from "@/components/assistant-ui/thread"; import { ChatHeader } from "@/components/new-chat/chat-header"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; @@ -25,6 +30,7 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; +import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; import { getBearerToken } from "@/lib/auth-utils"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { @@ -93,6 +99,7 @@ const PersistedAttachmentSchema = z.object({ id: z.string(), name: z.string(), type: z.string(), + contentType: z.string().optional(), imageDataUrl: z.string().optional(), extractedContent: z.string().optional(), }); @@ -155,6 +162,7 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { id: att.id, name: att.name, type: att.type as "document" | "image" | "file", + contentType: att.contentType || "application/octet-stream", status: { type: "complete" as const }, content: [], // Custom fields for our ChatAttachment interface @@ -181,6 +189,7 @@ const TOOLS_WITH_UI = new Set([ "link_preview", "display_image", "scrape_webpage", + "write_todos", ]); /** @@ -213,6 +222,7 @@ export default function NewChatPage() { const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); + const hydratePlanState = useSetAtom(hydratePlanStateAtom); // Create the attachment adapter for file processing const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); @@ -248,6 +258,7 @@ export default function NewChatPage() { setMentionedDocumentIds([]); setMentionedDocuments([]); setMessageDocumentsMap({}); + clearPlanOwnerRegistry(); // Reset plan ownership for new chat try { if (urlChatId > 0) { @@ -269,6 +280,11 @@ export default function NewChatPage() { if (steps.length > 0) { restoredThinkingSteps.set(`msg-${msg.id}`, steps); } + // Hydrate write_todos plan state from persisted tool calls + const writeTodosCalls = extractWriteTodosFromContent(msg.content); + for (const todoData of writeTodosCalls) { + hydratePlanState(todoData); + } } if (msg.role === "user") { const docs = extractMentionedDocuments(msg.content); @@ -297,7 +313,7 @@ export default function NewChatPage() { } finally { setIsInitializing(false); } - }, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments]); + }, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments, hydratePlanState]); // Initialize on mount useEffect(() => { @@ -425,6 +441,7 @@ export default function NewChatPage() { id: att.id, name: att.name, type: att.type, + contentType: (att as { contentType?: string }).contentType, // Include imageDataUrl for images so they can be displayed after reload imageDataUrl: (att as { imageDataUrl?: string }).imageDataUrl, // Include extractedContent for context (already extracted, no re-processing needed) @@ -844,6 +861,7 @@ export default function NewChatPage() { +
latest plan state + * Using title as key since it stays constant across updates + */ +export const planStatesAtom = atom>(new Map()); + +/** + * Input type for updating plan state + */ +export interface UpdatePlanInput { + id: string; + title: string; + description?: string; + todos: PlanTodo[]; + toolCallId: string; +} + +/** + * Helper atom to update a plan state + */ +export const updatePlanStateAtom = atom( + null, + (get, set, plan: UpdatePlanInput) => { + const states = new Map(get(planStatesAtom)); + + // Register ownership synchronously if not already done + registerPlanOwner(plan.title, plan.toolCallId); + + // Get the actual owner from the first plan + const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId; + + // Always use the canonical (first) title for the plan key + const canonicalTitle = getCanonicalPlanTitle(plan.title); + + states.set(canonicalTitle, { + id: plan.id, + title: canonicalTitle, + description: plan.description, + todos: plan.todos, + lastUpdated: Date.now(), + ownerToolCallId, + }); + set(planStatesAtom, states); + } +); + +/** + * Helper atom to get the latest plan state by title + */ +export const getPlanStateAtom = atom((get) => { + const states = get(planStatesAtom); + return (title: string) => states.get(title); +}); + +/** + * Helper atom to clear all plan states (useful when starting a new chat) + */ +export const clearPlanStatesAtom = atom(null, (get, set) => { + clearPlanOwnerRegistry(); + set(planStatesAtom, new Map()); +}); + +/** + * Hydrate plan state from persisted message content + * Call this when loading messages from the database to restore plan state + */ +export interface HydratePlanInput { + toolCallId: string; + result: { + id?: string; + title?: string; + description?: string; + todos?: Array<{ + id: string; + label: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + description?: string; + }>; + }; +} + +export const hydratePlanStateAtom = atom( + null, + (get, set, plan: HydratePlanInput) => { + if (!plan.result?.todos || plan.result.todos.length === 0) return; + + const states = new Map(get(planStatesAtom)); + const title = plan.result.title || "Planning Approach"; + + // Register this as the owner if no plan exists yet + registerPlanOwner(title, plan.toolCallId); + + // Get the canonical title + const canonicalTitle = getCanonicalPlanTitle(title); + const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId; + + // Only set if this is newer or doesn't exist + const existing = states.get(canonicalTitle); + if (!existing) { + states.set(canonicalTitle, { + id: plan.result.id || `plan-${Date.now()}`, + title: canonicalTitle, + description: plan.result.description, + todos: plan.result.todos, + lastUpdated: Date.now(), + ownerToolCallId, + }); + set(planStatesAtom, states); + } + } +); + +/** + * Extract write_todos tool call data from message content + * Returns an array of { toolCallId, result } for each write_todos call found + */ +export function extractWriteTodosFromContent(content: unknown): HydratePlanInput[] { + if (!Array.isArray(content)) return []; + + const results: HydratePlanInput[] = []; + + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + "type" in part && + (part as { type: string }).type === "tool-call" && + "toolName" in part && + (part as { toolName: string }).toolName === "write_todos" && + "toolCallId" in part && + "result" in part + ) { + const toolCall = part as { + toolCallId: string; + result: HydratePlanInput["result"]; + }; + if (toolCall.result) { + results.push({ + toolCallId: toolCall.toolCallId, + result: toolCall.result, + }); + } + } + } + + return results; +} + diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index c863d8722..93474acdf 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -60,3 +60,17 @@ export { type ScrapeWebpageResult, ScrapeWebpageToolUI, } from "./scrape-webpage"; +export { + Plan, + PlanErrorBoundary, + type PlanProps, + parseSerializablePlan, + type SerializablePlan, + type PlanTodo, + type TodoStatus, +} from "./plan"; +export { + WriteTodosToolUI, + type WriteTodosArgs, + type WriteTodosResult, +} from "./write-todos"; diff --git a/surfsense_web/components/tool-ui/plan/index.tsx b/surfsense_web/components/tool-ui/plan/index.tsx new file mode 100644 index 000000000..989daede9 --- /dev/null +++ b/surfsense_web/components/tool-ui/plan/index.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import { Card, CardContent } from "@/components/ui/card"; + +export * from "./plan"; +export * from "./schema"; + +// ============================================================================ +// Error Boundary +// ============================================================================ + +interface PlanErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface PlanErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +export class PlanErrorBoundary extends Component { + constructor(props: PlanErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): PlanErrorBoundaryState { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + + +
+ Failed to render plan +
+
+
+ ); + } + + return this.props.children; + } +} + diff --git a/surfsense_web/components/tool-ui/plan/plan.tsx b/surfsense_web/components/tool-ui/plan/plan.tsx new file mode 100644 index 000000000..a520ea416 --- /dev/null +++ b/surfsense_web/components/tool-ui/plan/plan.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { + CheckCircle2, + Circle, + CircleDashed, + PartyPopper, + XCircle, +} from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { useMemo, useState } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; +import type { Action, ActionsConfig } from "../shared/schema"; +import type { PlanTodo, TodoStatus } from "./schema"; + +// ============================================================================ +// Status Icon Component +// ============================================================================ + +interface StatusIconProps { + status: TodoStatus; + className?: string; +} + +const StatusIcon: FC = ({ status, className }) => { + const baseClass = cn("size-4 shrink-0", className); + + switch (status) { + case "completed": + return ; + case "in_progress": + return ( + + ); + case "cancelled": + return ; + case "pending": + default: + return ; + } +}; + +// ============================================================================ +// Todo Item Component +// ============================================================================ + +interface TodoItemProps { + todo: PlanTodo; +} + +const TodoItem: FC = ({ todo }) => { + const isStrikethrough = todo.status === "completed" || todo.status === "cancelled"; + const isShimmer = todo.status === "in_progress"; + + if (todo.description) { + return ( + + +
+ + + {todo.label} + +
+
+ +

{todo.description}

+
+
+ ); + } + + return ( +
+ + + {todo.label} + +
+ ); +}; + +// ============================================================================ +// Plan Component +// ============================================================================ + +export interface PlanProps { + id: string; + title: string; + description?: string; + todos: PlanTodo[]; + maxVisibleTodos?: number; + showProgress?: boolean; + responseActions?: Action[] | ActionsConfig; + className?: string; + onResponseAction?: (actionId: string) => void; + onBeforeResponseAction?: (actionId: string) => boolean; +} + +export const Plan: FC = ({ + id, + title, + description, + todos, + maxVisibleTodos = 4, + showProgress = true, + responseActions, + className, + onResponseAction, + onBeforeResponseAction, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + // Calculate progress + const progress = useMemo(() => { + const completed = todos.filter((t) => t.status === "completed").length; + const total = todos.filter((t) => t.status !== "cancelled").length; + return { completed, total, percentage: total > 0 ? (completed / total) * 100 : 0 }; + }, [todos]); + + const isAllComplete = progress.completed === progress.total && progress.total > 0; + + // Split todos for collapsible display + const visibleTodos = todos.slice(0, maxVisibleTodos); + const hiddenTodos = todos.slice(maxVisibleTodos); + const hasHiddenTodos = hiddenTodos.length > 0; + + // Check if any todo has a description (for accordion mode) + const hasDescriptions = todos.some((t) => t.description); + + // Handle action click + const handleAction = (actionId: string) => { + if (onBeforeResponseAction && !onBeforeResponseAction(actionId)) { + return; + } + onResponseAction?.(actionId); + }; + + // Normalize actions to array + const actionArray: Action[] = useMemo(() => { + if (!responseActions) return []; + if (Array.isArray(responseActions)) return responseActions; + return [ + responseActions.confirm && { ...responseActions.confirm, id: "confirm" }, + responseActions.cancel && { ...responseActions.cancel, id: "cancel" }, + ].filter(Boolean) as Action[]; + }, [responseActions]); + + const TodoList: FC<{ items: PlanTodo[] }> = ({ items }) => { + if (hasDescriptions) { + return ( + + {items.map((todo) => ( + + ))} + + ); + } + + return ( +
+ {items.map((todo) => ( + + ))} +
+ ); + }; + + return ( + + +
+
+ {title} + {description && ( + {description} + )} +
+ {isAllComplete && ( +
+ +
+ )} +
+ + {showProgress && ( +
+
+ + {progress.completed} of {progress.total} complete + + {Math.round(progress.percentage)}% +
+ +
+ )} +
+ + + + + {hasHiddenTodos && ( + + + + + + + + + )} + + {actionArray.length > 0 && ( +
+ {actionArray.map((action) => ( + + ))} +
+ )} +
+
+ ); +}; + diff --git a/surfsense_web/components/tool-ui/plan/schema.ts b/surfsense_web/components/tool-ui/plan/schema.ts new file mode 100644 index 000000000..e72233d03 --- /dev/null +++ b/surfsense_web/components/tool-ui/plan/schema.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { ActionSchema } from "../shared/schema"; + +/** + * Todo item status + */ +export const TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]); +export type TodoStatus = z.infer; + +/** + * Single todo item in a plan + */ +export const PlanTodoSchema = z.object({ + id: z.string(), + label: z.string(), + status: TodoStatusSchema, + description: z.string().optional(), +}); + +export type PlanTodo = z.infer; + +/** + * Serializable plan schema for tool results + */ +export const SerializablePlanSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + todos: z.array(PlanTodoSchema).min(1), + maxVisibleTodos: z.number().optional(), + showProgress: z.boolean().optional(), +}); + +export type SerializablePlan = z.infer; + +/** + * Parse and validate a serializable plan from tool result + */ +export function parseSerializablePlan(data: unknown): SerializablePlan { + const result = SerializablePlanSchema.safeParse(data); + + if (!result.success) { + console.warn("Invalid plan data:", result.error.issues); + + // Try to extract basic info for fallback + const obj = (data && typeof data === "object" ? data : {}) as Record; + + return { + id: typeof obj.id === "string" ? obj.id : "unknown", + title: typeof obj.title === "string" ? obj.title : "Plan", + description: typeof obj.description === "string" ? obj.description : undefined, + todos: Array.isArray(obj.todos) + ? obj.todos.map((t, i) => ({ + id: typeof (t as any)?.id === "string" ? (t as any).id : `todo-${i}`, + label: typeof (t as any)?.label === "string" ? (t as any).label : "Task", + status: TodoStatusSchema.safeParse((t as any)?.status).success + ? (t as any).status + : "pending", + description: typeof (t as any)?.description === "string" ? (t as any).description : undefined, + })) + : [{ id: "1", label: "No tasks", status: "pending" as const }], + }; + } + + return result.data; +} + diff --git a/surfsense_web/components/tool-ui/shared/action-buttons.tsx b/surfsense_web/components/tool-ui/shared/action-buttons.tsx new file mode 100644 index 000000000..61141647f --- /dev/null +++ b/surfsense_web/components/tool-ui/shared/action-buttons.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { FC } from "react"; +import { Button } from "@/components/ui/button"; +import type { Action, ActionsConfig } from "./schema"; + +interface ActionButtonsProps { + actions?: Action[] | ActionsConfig; + onAction?: (actionId: string) => void; + disabled?: boolean; +} + +export const ActionButtons: FC = ({ actions, onAction, disabled }) => { + if (!actions) return null; + + // Normalize actions to array format + const actionArray: Action[] = Array.isArray(actions) + ? actions + : [ + actions.confirm && { ...actions.confirm, id: "confirm" }, + actions.cancel && { ...actions.cancel, id: "cancel" }, + ].filter(Boolean) as Action[]; + + if (actionArray.length === 0) return null; + + return ( +
+ {actionArray.map((action) => ( + + ))} +
+ ); +}; + diff --git a/surfsense_web/components/tool-ui/shared/index.ts b/surfsense_web/components/tool-ui/shared/index.ts new file mode 100644 index 000000000..fae3af451 --- /dev/null +++ b/surfsense_web/components/tool-ui/shared/index.ts @@ -0,0 +1,3 @@ +export * from "./schema"; +export * from "./action-buttons"; + diff --git a/surfsense_web/components/tool-ui/shared/schema.ts b/surfsense_web/components/tool-ui/shared/schema.ts new file mode 100644 index 000000000..71c2422b9 --- /dev/null +++ b/surfsense_web/components/tool-ui/shared/schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * Shared action schema for tool UI components + */ +export const ActionSchema = z.object({ + id: z.string(), + label: z.string(), + variant: z.enum(["default", "secondary", "destructive", "outline", "ghost", "link"]).optional(), + disabled: z.boolean().optional(), +}); + +export type Action = z.infer; + +/** + * Actions configuration schema + */ +export const ActionsConfigSchema = z.object({ + confirm: ActionSchema.optional(), + cancel: ActionSchema.optional(), +}); + +export type ActionsConfig = z.infer; + diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx new file mode 100644 index 000000000..a8a7eaf43 --- /dev/null +++ b/surfsense_web/components/tool-ui/write-todos.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import { + getCanonicalPlanTitle, + planStatesAtom, + registerPlanOwner, + updatePlanStateAtom, +} from "@/atoms/chat/plan-state.atom"; +import { Plan, PlanErrorBoundary, parseSerializablePlan } from "./plan"; + +/** + * Tool arguments for write_todos + */ +interface WriteTodosArgs { + title?: string; + description?: string; + todos?: Array<{ + id: string; + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + }>; +} + +/** + * Tool result for write_todos + */ +interface WriteTodosResult { + id: string; + title: string; + description?: string; + todos: Array<{ + id: string; + label: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + description?: string; + }>; +} + +/** + * Loading state component + */ +function WriteTodosLoading() { + return ( +
+
+ + Creating plan... +
+
+ ); +} + +/** + * Transform tool args to result format + * This handles the case where the LLM is streaming the tool call + */ +function transformArgsToResult(args: WriteTodosArgs): WriteTodosResult | null { + if (!args.todos || !Array.isArray(args.todos) || args.todos.length === 0) { + return null; + } + + return { + id: `plan-${Date.now()}`, + title: args.title || "Planning Approach", + description: args.description, + todos: args.todos.map((todo, index) => ({ + id: todo.id || `todo-${index}`, + label: todo.content || "Task", + status: todo.status || "pending", + })), + }; +} + +/** + * WriteTodos Tool UI Component + * + * Displays the agent's planning/todo list with a beautiful UI. + * Shows progress, status indicators, and expandable details. + * + * FIXED POSITION: When the same plan (by title) is updated multiple times, + * only the FIRST component renders. Subsequent updates just update the + * shared state, and the first component reads from it. This prevents + * layout shift when plans are updated. + */ +export const WriteTodosToolUI = makeAssistantToolUI({ + toolName: "write_todos", + render: function WriteTodosUI({ args, result, status, toolCallId }) { + const updatePlanState = useSetAtom(updatePlanStateAtom); + const planStates = useAtomValue(planStatesAtom); + + // Get the plan data (from result or args) + const planData = result || transformArgsToResult(args); + const rawTitle = planData?.title || args.title || "Planning Approach"; + + // SYNCHRONOUS ownership check - happens immediately, no race conditions + // ONE PLAN PER CONVERSATION: Only first write_todos call becomes owner + const isOwner = useMemo(() => { + return registerPlanOwner(rawTitle, toolCallId); + }, [rawTitle, toolCallId]); + + // Get canonical title - always use the FIRST plan's title + // This ensures all updates go to the same plan state + const planTitle = useMemo(() => getCanonicalPlanTitle(rawTitle), [rawTitle]); + + // Register/update the plan state - ALWAYS use canonical title + useEffect(() => { + if (planData) { + updatePlanState({ + id: planData.id, + title: planTitle, // Use canonical title, not raw title + description: planData.description, + todos: planData.todos, + toolCallId, + }); + } + }, [planData, planTitle, updatePlanState, toolCallId]); + + // Update when result changes (for streaming updates) + useEffect(() => { + if (result) { + updatePlanState({ + id: result.id, + title: planTitle, // Use canonical title, not raw title + description: result.description, + todos: result.todos, + toolCallId, + }); + } + }, [result, planTitle, updatePlanState, toolCallId]); + + // Get the current plan state (may be updated by other components) + const currentPlanState = planStates.get(planTitle); + + // If we're NOT the owner, render nothing (the owner will render) + if (!isOwner) { + return null; + } + + // Loading state - tool is still running + if (status.type === "running" || status.type === "requires-action") { + // Try to show partial results from args while streaming + const partialResult = transformArgsToResult(args); + if (partialResult) { + const plan = parseSerializablePlan(partialResult); + return ( +
+ + + +
+ ); + } + return ; + } + + // Incomplete/cancelled state + if (status.type === "incomplete") { + if (status.reason === "cancelled") { + return null; + } + // For errors, try to show what we have from args or shared state + const fallbackResult = currentPlanState || transformArgsToResult(args); + if (fallbackResult) { + const plan = parseSerializablePlan(fallbackResult); + return ( +
+ + + +
+ ); + } + return null; + } + + // Success - render the plan using the LATEST shared state + // This way, even if our result is old, we show the latest data + const planToRender = currentPlanState || result; + if (!planToRender) { + return ; + } + + const plan = parseSerializablePlan(planToRender); + return ( +
+ + + +
+ ); + }, +}); + +export type { WriteTodosArgs, WriteTodosResult }; + From 2860b789e3c47fba955a63093ee0589f8ebbfa25 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:40:15 +0530 Subject: [PATCH 06/24] chore: removed auto-focus implementation --- .../components/assistant-ui/thread.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index fa633a55a..04b2f28bd 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -409,7 +409,6 @@ const Composer: FC = () => { const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); - const prevIsThreadEmptyRef = useRef(true); // Check if thread is empty (new chat) const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); @@ -429,22 +428,6 @@ const Composer: FC = () => { } }, [isThreadEmpty]); - // Auto-focus editor immediately after first message is sent (when thread transitions from empty to non-empty) - // This allows the user to start typing the second query right away - useEffect(() => { - // Only focus when transitioning from empty to non-empty (first message sent) - if (prevIsThreadEmptyRef.current && !isThreadEmpty && editorRef.current) { - // Small delay to ensure the editor is ready after the message is sent - const timeoutId = setTimeout(() => { - editorRef.current?.focus(); - }, 50); - prevIsThreadEmptyRef.current = isThreadEmpty; - return () => clearTimeout(timeoutId); - } - // Update the ref to track the previous state - prevIsThreadEmptyRef.current = isThreadEmpty; - }, [isThreadEmpty]); - // Sync mentioned document IDs to atom for use in chat request useEffect(() => { setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); From 5c68820c2a01d5d66200e4d69504c9f55fdc40fa Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:01:22 +0530 Subject: [PATCH 07/24] refactor: enhance chat and tool UI with improved streaming and error handling - Updated NewChatPage to persist partial responses when requests are cancelled by the user. - Enhanced WriteTodosToolUI to check if the thread is running, improving the display of loading states and error handling. - Modified Plan and TodoItem components to conditionally animate in-progress items based on streaming status, providing a clearer user experience during task management. --- .../new-chat/[[...chat_id]]/page.tsx | 16 +++++++++- .../components/tool-ui/plan/plan.tsx | 32 +++++++++++++------ .../components/tool-ui/write-todos.tsx | 23 +++++++------ 3 files changed, 51 insertions(+), 20 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 df092a630..5831a0069 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 @@ -755,7 +755,21 @@ export default function NewChatPage() { } } catch (error) { if (error instanceof Error && error.name === "AbortError") { - // Request was cancelled + // Request was cancelled by user - persist partial response if any content was received + const hasContent = contentParts.some( + (part) => + (part.type === "text" && part.text.length > 0) || + (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) + ); + if (hasContent && currentThreadId) { + const partialContent = buildContentForPersistence(); + appendMessage(currentThreadId, { + role: "assistant", + content: partialContent, + }).catch((err) => + console.error("Failed to persist partial assistant message:", err) + ); + } return; } console.error("[NewChatPage] Chat error:", error); diff --git a/surfsense_web/components/tool-ui/plan/plan.tsx b/surfsense_web/components/tool-ui/plan/plan.tsx index a520ea416..472cc41b8 100644 --- a/surfsense_web/components/tool-ui/plan/plan.tsx +++ b/surfsense_web/components/tool-ui/plan/plan.tsx @@ -30,19 +30,27 @@ import type { PlanTodo, TodoStatus } from "./schema"; interface StatusIconProps { status: TodoStatus; className?: string; + /** When false, in_progress items show as static (no spinner) */ + isStreaming?: boolean; } -const StatusIcon: FC = ({ status, className }) => { +const StatusIcon: FC = ({ status, className, isStreaming = true }) => { const baseClass = cn("size-4 shrink-0", className); switch (status) { case "completed": return ; case "in_progress": + // Only animate the spinner if we're actively streaming + // When streaming is stopped, show as a static dashed circle return ( ); case "cancelled": @@ -59,18 +67,21 @@ const StatusIcon: FC = ({ status, className }) => { interface TodoItemProps { todo: PlanTodo; + /** When false, in_progress items show as static (no spinner/pulse) */ + isStreaming?: boolean; } -const TodoItem: FC = ({ todo }) => { +const TodoItem: FC = ({ todo, isStreaming = true }) => { const isStrikethrough = todo.status === "completed" || todo.status === "cancelled"; - const isShimmer = todo.status === "in_progress"; + // Only show pulse animation if streaming and in progress + const isShimmer = todo.status === "in_progress" && isStreaming; if (todo.description) { return (
- + = ({ todo }) => { return (
- + void; @@ -129,6 +142,7 @@ export const Plan: FC = ({ todos, maxVisibleTodos = 4, showProgress = true, + isStreaming = true, responseActions, className, onResponseAction, @@ -176,7 +190,7 @@ export const Plan: FC = ({ return ( {items.map((todo) => ( - + ))} ); @@ -185,7 +199,7 @@ export const Plan: FC = ({ return (
{items.map((todo) => ( - + ))}
); diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx index a8a7eaf43..6d8e0446b 100644 --- a/surfsense_web/components/tool-ui/write-todos.tsx +++ b/surfsense_web/components/tool-ui/write-todos.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { Loader2 } from "lucide-react"; import { useEffect, useMemo } from "react"; @@ -91,6 +91,10 @@ export const WriteTodosToolUI = makeAssistantToolUI thread.isRunning); // Get the plan data (from result or args) const planData = result || transformArgsToResult(args); @@ -140,7 +144,7 @@ export const WriteTodosToolUI = makeAssistantToolUI - +
); @@ -159,17 +163,15 @@ export const WriteTodosToolUI = makeAssistantToolUI - +
); @@ -178,7 +180,8 @@ export const WriteTodosToolUI = makeAssistantToolUI; @@ -188,7 +191,7 @@ export const WriteTodosToolUI = makeAssistantToolUI - +
); From 8a32a310f82477aaacac5bd0dd05cb5f9f10d9a2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:52:35 +0530 Subject: [PATCH 08/24] feat: add TextShimmerLoader component for enhanced loading states - Introduced a new TextShimmerLoader component to provide a shimmer effect for loading text, improving user experience during in-progress states. - Updated the TodoItem component to utilize the TextShimmerLoader for displaying labels of in-progress tasks, replacing the previous pulse animation. --- .../components/prompt-kit/loader.tsx | 74 +++++++++++++++++++ .../components/tool-ui/plan/plan.tsx | 42 ++++++----- 2 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 surfsense_web/components/prompt-kit/loader.tsx diff --git a/surfsense_web/components/prompt-kit/loader.tsx b/surfsense_web/components/prompt-kit/loader.tsx new file mode 100644 index 000000000..a51876e64 --- /dev/null +++ b/surfsense_web/components/prompt-kit/loader.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export interface LoaderProps { + variant?: "text-shimmer"; + size?: "sm" | "md" | "lg"; + text?: string; + className?: string; +} + +const textSizes = { + sm: "text-xs", + md: "text-sm", + lg: "text-base", +} as const; + +/** + * TextShimmerLoader - A text loader with a shimmer gradient animation + * Used for in-progress states in write_todos and chain-of-thought + */ +export function TextShimmerLoader({ + text = "Thinking", + className, + size = "md", +}: { + text?: string; + className?: string; + size?: "sm" | "md" | "lg"; +}) { + return ( + <> + + + {text} + + + ); +} + +/** + * Loader component - currently only supports text-shimmer variant + * Can be extended with more variants if needed in the future + */ +export function Loader({ + variant = "text-shimmer", + size = "md", + text, + className, +}: LoaderProps) { + switch (variant) { + case "text-shimmer": + default: + return ( + + ); + } +} + diff --git a/surfsense_web/components/tool-ui/plan/plan.tsx b/surfsense_web/components/tool-ui/plan/plan.tsx index 472cc41b8..90ac5c614 100644 --- a/surfsense_web/components/tool-ui/plan/plan.tsx +++ b/surfsense_web/components/tool-ui/plan/plan.tsx @@ -7,7 +7,7 @@ import { PartyPopper, XCircle, } from "lucide-react"; -import type { FC, ReactNode } from "react"; +import type { FC } from "react"; import { useMemo, useState } from "react"; import { Accordion, @@ -18,6 +18,7 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; import type { Action, ActionsConfig } from "../shared/schema"; @@ -73,24 +74,33 @@ interface TodoItemProps { const TodoItem: FC = ({ todo, isStreaming = true }) => { const isStrikethrough = todo.status === "completed" || todo.status === "cancelled"; - // Only show pulse animation if streaming and in progress + // Only show shimmer animation if streaming and in progress const isShimmer = todo.status === "in_progress" && isStreaming; + // Render the label with optional shimmer effect + const renderLabel = () => { + if (isShimmer) { + return ; + } + return ( + + {todo.label} + + ); + }; + if (todo.description) { return (
- - {todo.label} - + {renderLabel()}
@@ -103,15 +113,7 @@ const TodoItem: FC = ({ todo, isStreaming = true }) => { return (
- - {todo.label} - + {renderLabel()}
); }; From 2c8628726459681fb355d08b7a0714a757e079f9 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:27:30 +0530 Subject: [PATCH 09/24] refactor: update system prompt for write_todos tool usage - Enhanced the system prompt with detailed criteria and examples for using the write_todos tool, including valid trigger words and usage patterns. - Clarified restrictions on when to use the tool, emphasizing the need for multi-phase tasks and explicit user requests. - Provided specific use cases for different user types to guide appropriate usage of the tool, ensuring better task management and user experience. --- .../app/agents/new_chat/system_prompt.py | 122 +++++++++++++++--- 1 file changed, 102 insertions(+), 20 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index d85a89db7..24d42d60c 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -111,13 +111,27 @@ You have access to the following tools: * Don't show every image - just the most relevant 1-3 images that enhance understanding. 6. write_todos: Create and update a planning/todo list to break down complex tasks. - - Use this tool when you need to plan your approach to a complex task. - - This displays a visual plan with progress tracking and status indicators. + + - STRICT USAGE CRITERIA - MUST MEET ALL CONDITIONS: + * Condition 1: User EXPLICITLY requests structured output using trigger words (see below) + * Condition 2: Task requires 4+ DISTINCT phases/steps to achieve + * Condition 3: Task involves CREATING, BUILDING, ACHIEVING, or PRODUCING something + + - VALID TRIGGER WORDS/PHRASES (user must use one of these): + * "plan" / "make a plan" / "create a plan" / "planning" + * "roadmap" / "create a roadmap" + * "step-by-step" / "step by step" + * "break down" / "breakdown" + * "walk me through" + * "guide me through" + * "how should I approach" + * "help me create" / "help me build" / "help me achieve" - USAGE PATTERN: * First call: Create the plan with first task as "in_progress", rest as "pending" * Subsequent calls: ONLY update task statuses (mark completed/in_progress) * Use the EXACT SAME title and task IDs for all updates + * ONLY ONE PLAN PER CONVERSATION - never create a second plan - ABSOLUTELY FORBIDDEN - WILL BREAK THE SYSTEM: * ONLY ONE PLAN PER CONVERSATION - NEVER call write_todos a second time to create a new plan @@ -132,22 +146,57 @@ You have access to the following tools: * Do NOT use phrases like "This report is based on..." or "Based on my research..." * Just answer the question directly - do not roleplay producing a deliverable - - CORRECT BEHAVIOR: - * Call write_todos to update statuses as you progress - * Each section of your response appears EXACTLY ONCE - * When you finish explaining all tasks, your response is COMPLETE - * Do NOT generate additional content after concluding - - CONTENT QUALITY: * Provide thorough, detailed explanations for each task * The restriction is on DUPLICATING content, not on depth or detail * Each task deserves a complete, comprehensive explanation * Be as detailed as needed - just don't repeat yourself - - When to use: - * Breaking down a complex multi-step task (3-5 tasks recommended) - * Showing the user what steps you'll take to solve their problem - * Creating an implementation roadmap + - VALID USE CASES BY USER TYPE: + + RESEARCHERS/STUDENTS: + * "Help me plan my thesis research on X" - has "plan" + multi-phase project + * "Create a roadmap for my dissertation" - has "roadmap" + structured work + * "Break down my literature review process" - has "break down" + phases + + WRITERS/CONTENT CREATORS: + * "Help me plan my book outline" - has "plan" + creative project + * "Walk me through writing a research paper" - has trigger + structured work + + BUSINESS/PROFESSIONALS: + * "Create a plan for launching my product" - has "plan" + business goal + * "Break down the hiring process for my team" - has "break down" + phases + + PERSONAL/LIFESTYLE: + * "Help me plan my career transition" - has "plan" + life goal + * "Create a roadmap for learning a new skill" - has "roadmap" + phases + + TECHNICAL: + * "Help me plan implementing authentication" - has "plan" + implementation + * "Create a roadmap for this API" - has "roadmap" + technical project + + - ABSOLUTELY DO NOT USE FOR (even if task seems complex): + * Simple questions: "What is X?", "How does Y work?", "Explain Z" + * Summaries: "Summarize this", "Key points of", "Overview of" + * Document explanations: "Explain this PDF", "What does this article say" + * Comparisons: "Compare X and Y", "Difference between" + * Searches/Lookups: "Find X", "Search for Y", "What did I save about" + * Quick recommendations: "What should I read about X" + * Opinions/Analysis: "What do you think of", "Analyze this" + * Podcast generation, link previews, image display, single searches + * Any single-response task that does not require multiple phases + + - CRITICAL DISTINCTION: + * EXPLAINING something = NO write_todos (just explain directly) + * CREATING/PLANNING something = YES write_todos (if 4+ phases and trigger word used) + + - SELF-CHECK (must answer YES to ALL before using): + 1. Did the user use a valid trigger word from the list above? If NO -> DO NOT USE + 2. Is user asking to CREATE/PLAN/ACHIEVE something (not just explain)? If NO -> DO NOT USE + 3. Does this require 4+ distinct phases to complete? If NO -> DO NOT USE + 4. Would a direct response be faster and better for the user? If YES -> DO NOT USE + + - DEFAULT BEHAVIOR: When in doubt, DO NOT use write_todos. Fast responses beat unnecessary plans. - Args: - todos: List of todo items, each with: @@ -226,21 +275,54 @@ You have access to the following tools: - Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")` - Then provide your explanation, referencing the displayed image -- User: "Help me implement a user authentication system" +- User: "Help me plan implementing a user authentication system" + - Has trigger word "plan" + implementation task with 4+ phases -> USE write_todos - Step 1: Create plan with task 1 in_progress: - `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "in_progress"}, {"id": "2", "content": "Set up password hashing", "status": "pending"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` + `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "in_progress"}, {"id": "2", "content": "Set up password hashing", "status": "pending"}, {"id": "3", "content": "Create endpoints", "status": "pending"}, {"id": "4", "content": "Add session management", "status": "pending"}])` - Step 2: Provide DETAILED explanation of database schema design - Step 3: Update plan (task 1 done, task 2 in_progress): - `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "completed"}, {"id": "2", "content": "Set up password hashing", "status": "in_progress"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` + `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "completed"}, {"id": "2", "content": "Set up password hashing", "status": "in_progress"}, {"id": "3", "content": "Create endpoints", "status": "pending"}, {"id": "4", "content": "Add session management", "status": "pending"}])` - Step 4: Provide DETAILED explanation of password hashing (NEW content only) - - Step 5: Update plan, explain endpoints in detail + - Step 5: Continue updating plan and explaining each task - Step 6: Mark all complete, END response - DO NOT restart or regenerate - FORBIDDEN: Do not go back and explain schema again after step 2 -- User: "How should I approach refactoring this large codebase?" - - Create plan, explain each step with thorough detail, update statuses as you go - - Each explanation is comprehensive but appears ONLY ONCE - - When finished with all tasks, STOP - do not continue generating +- User: "Create a roadmap for my thesis research" + - Has trigger word "roadmap" + multi-phase project -> USE write_todos + - Create plan with 4+ research phases, update as you explain each + +- User: "Walk me through building a marketing campaign step-by-step" + - Has trigger words "walk me through" and "step-by-step" + creation task -> USE write_todos + +EXAMPLES OF WHEN NOT TO USE write_todos: + +- User: "Explain this PDF document" + - NO trigger word, EXPLAINING not creating -> DO NOT use write_todos + - Just explain the document content directly + +- User: "What is machine learning?" + - Simple question, NO trigger word -> DO NOT use write_todos + - Just answer directly + +- User: "Summarize this article for me" + - Summary request, NO trigger word -> DO NOT use write_todos + - Just provide the summary directly + +- User: "Compare React and Vue" + - Comparison request, NO trigger word -> DO NOT use write_todos + - Just compare them directly + +- User: "What did I discuss on Slack last week?" + - Search request, NO trigger word -> DO NOT use write_todos + - Just search and present results + +- User: "Give me an overview of this research paper" + - Explanation request, NO trigger word -> DO NOT use write_todos + - Just provide the overview directly + +- User: "How does async/await work in JavaScript?" + - Explanation request, NO trigger word -> DO NOT use write_todos + - Just explain directly """ From ebc04f590ebcb8d9697ab5a9f951b79e9d935b98 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:49:56 +0530 Subject: [PATCH 10/24] refactor: improve write_todos tool and UI components - Refactored the write_todos tool to enhance argument and result schemas using Zod for better validation and type safety. - Updated the WriteTodosToolUI to streamline the rendering logic and improve loading states, ensuring a smoother user experience. - Enhanced the Plan and TodoItem components to better handle streaming states and display progress, providing clearer feedback during task management. - Cleaned up code formatting and structure for improved readability and maintainability. --- .../app/agents/new_chat/tools/write_todos.py | 3 +- .../app/connectors/webcrawler_connector.py | 9 +- .../new-chat/[[...chat_id]]/page.tsx | 18 +- surfsense_web/atoms/chat/plan-state.atom.ts | 253 +++++------ .../components/assistant-ui/thread.tsx | 2 +- .../components/prompt-kit/loader.tsx | 82 ++-- .../components/sidebar/AppSidebarProvider.tsx | 30 +- .../components/tool-ui/display-image.tsx | 62 ++- surfsense_web/components/tool-ui/index.ts | 10 + .../components/tool-ui/link-preview.tsx | 103 +++-- .../components/tool-ui/plan/index.tsx | 57 ++- .../components/tool-ui/plan/plan.tsx | 422 +++++++++--------- .../components/tool-ui/plan/schema.ts | 75 ++-- .../components/tool-ui/scrape-webpage.tsx | 68 ++- .../tool-ui/shared/action-buttons.tsx | 55 ++- .../components/tool-ui/shared/index.ts | 1 - .../components/tool-ui/shared/schema.ts | 13 +- .../components/tool-ui/write-todos.tsx | 321 ++++++------- 18 files changed, 833 insertions(+), 751 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/tools/write_todos.py b/surfsense_backend/app/agents/new_chat/tools/write_todos.py index 95b5cb155..f747d891f 100644 --- a/surfsense_backend/app/agents/new_chat/tools/write_todos.py +++ b/surfsense_backend/app/agents/new_chat/tools/write_todos.py @@ -5,7 +5,7 @@ This module provides a tool for creating and displaying a planning/todo list in the chat UI. It helps the agent break down complex tasks into steps. """ -from typing import Any, Literal +from typing import Any from langchain_core.tools import tool @@ -91,4 +91,3 @@ def create_write_todos_tool(): } return write_todos - diff --git a/surfsense_backend/app/connectors/webcrawler_connector.py b/surfsense_backend/app/connectors/webcrawler_connector.py index 411f99a51..7ffc66644 100644 --- a/surfsense_backend/app/connectors/webcrawler_connector.py +++ b/surfsense_backend/app/connectors/webcrawler_connector.py @@ -76,12 +76,17 @@ class WebCrawlerConnector: return result, None except Exception as firecrawl_error: # Firecrawl failed, fallback to Chromium - logger.warning(f"[webcrawler] Firecrawl failed, falling back to Chromium+Trafilatura for: {url}") + logger.warning( + f"[webcrawler] Firecrawl failed, falling back to Chromium+Trafilatura for: {url}" + ) try: result = await self._crawl_with_chromium(url) return result, None except Exception as chromium_error: - return None, f"Both Firecrawl and Chromium failed. Firecrawl error: {firecrawl_error!s}, Chromium error: {chromium_error!s}" + return ( + None, + f"Both Firecrawl and Chromium failed. Firecrawl error: {firecrawl_error!s}, Chromium error: {chromium_error!s}", + ) else: # No Firecrawl API key, use Chromium directly logger.info(f"[webcrawler] Using Chromium+Trafilatura for: {url}") 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 1a6d173f3..35a096497 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 @@ -149,7 +149,11 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { if (typeof part !== "object" || part === null || !("type" in part)) return true; const partType = (part as { type: string }).type; // Filter out thinking-steps, mentioned-documents, and attachments - return partType !== "thinking-steps" && partType !== "mentioned-documents" && partType !== "attachments"; + return ( + partType !== "thinking-steps" && + partType !== "mentioned-documents" && + partType !== "attachments" + ); }); content = filteredContent.length > 0 @@ -319,7 +323,13 @@ export default function NewChatPage() { } finally { setIsInitializing(false); } - }, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments, hydratePlanState]); + }, [ + urlChatId, + setMessageDocumentsMap, + setMentionedDocumentIds, + setMentionedDocuments, + hydratePlanState, + ]); // Initialize on mount useEffect(() => { @@ -786,9 +796,7 @@ export default function NewChatPage() { appendMessage(currentThreadId, { role: "assistant", content: partialContent, - }).catch((err) => - console.error("Failed to persist partial assistant message:", err) - ); + }).catch((err) => console.error("Failed to persist partial assistant message:", err)); } return; } diff --git a/surfsense_web/atoms/chat/plan-state.atom.ts b/surfsense_web/atoms/chat/plan-state.atom.ts index c8a0815ce..22c33ff90 100644 --- a/surfsense_web/atoms/chat/plan-state.atom.ts +++ b/surfsense_web/atoms/chat/plan-state.atom.ts @@ -10,20 +10,20 @@ import { atom } from "jotai"; export interface PlanTodo { - id: string; - label: string; - status: "pending" | "in_progress" | "completed" | "cancelled"; - description?: string; + id: string; + label: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + description?: string; } export interface PlanState { - id: string; - title: string; - description?: string; - todos: PlanTodo[]; - lastUpdated: number; - /** The toolCallId of the first component that rendered this plan */ - ownerToolCallId: string; + id: string; + title: string; + description?: string; + todos: PlanTodo[]; + lastUpdated: number; + /** The toolCallId of the first component that rendered this plan */ + ownerToolCallId: string; } /** @@ -38,14 +38,14 @@ let firstPlanOwner: { toolCallId: string; title: string } | null = null; * All subsequent calls update the state but don't render their own card. */ export function registerPlanOwner(title: string, toolCallId: string): boolean { - if (!firstPlanOwner) { - // First plan in this conversation - claim ownership - firstPlanOwner = { toolCallId, title }; - return true; - } - - // Check if we're the owner - return firstPlanOwner.toolCallId === toolCallId; + if (!firstPlanOwner) { + // First plan in this conversation - claim ownership + firstPlanOwner = { toolCallId, title }; + return true; + } + + // Check if we're the owner + return firstPlanOwner.toolCallId === toolCallId; } /** @@ -53,35 +53,35 @@ export function registerPlanOwner(title: string, toolCallId: string): boolean { * Returns the first plan's title if one exists, otherwise the provided title */ export function getCanonicalPlanTitle(title: string): string { - return firstPlanOwner?.title || title; + return firstPlanOwner?.title || title; } /** * Check if a plan already exists in this conversation */ export function hasPlan(): boolean { - return firstPlanOwner !== null; + return firstPlanOwner !== null; } /** * Get the first plan's info */ export function getFirstPlanInfo(): { toolCallId: string; title: string } | null { - return firstPlanOwner; + return firstPlanOwner; } /** * Check if a toolCallId is the owner of the plan SYNCHRONOUSLY */ export function isPlanOwner(toolCallId: string): boolean { - return !firstPlanOwner || firstPlanOwner.toolCallId === toolCallId; + return !firstPlanOwner || firstPlanOwner.toolCallId === toolCallId; } /** * Clear ownership registry (call when starting a new chat) */ export function clearPlanOwnerRegistry(): void { - firstPlanOwner = null; + firstPlanOwner = null; } /** @@ -94,56 +94,53 @@ export const planStatesAtom = atom>(new Map()); * Input type for updating plan state */ export interface UpdatePlanInput { - id: string; - title: string; - description?: string; - todos: PlanTodo[]; - toolCallId: string; + id: string; + title: string; + description?: string; + todos: PlanTodo[]; + toolCallId: string; } /** * Helper atom to update a plan state */ -export const updatePlanStateAtom = atom( - null, - (get, set, plan: UpdatePlanInput) => { - const states = new Map(get(planStatesAtom)); - - // Register ownership synchronously if not already done - registerPlanOwner(plan.title, plan.toolCallId); - - // Get the actual owner from the first plan - const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId; - - // Always use the canonical (first) title for the plan key - const canonicalTitle = getCanonicalPlanTitle(plan.title); - - states.set(canonicalTitle, { - id: plan.id, - title: canonicalTitle, - description: plan.description, - todos: plan.todos, - lastUpdated: Date.now(), - ownerToolCallId, - }); - set(planStatesAtom, states); - } -); +export const updatePlanStateAtom = atom(null, (get, set, plan: UpdatePlanInput) => { + const states = new Map(get(planStatesAtom)); + + // Register ownership synchronously if not already done + registerPlanOwner(plan.title, plan.toolCallId); + + // Get the actual owner from the first plan + const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId; + + // Always use the canonical (first) title for the plan key + const canonicalTitle = getCanonicalPlanTitle(plan.title); + + states.set(canonicalTitle, { + id: plan.id, + title: canonicalTitle, + description: plan.description, + todos: plan.todos, + lastUpdated: Date.now(), + ownerToolCallId, + }); + set(planStatesAtom, states); +}); /** * Helper atom to get the latest plan state by title */ export const getPlanStateAtom = atom((get) => { - const states = get(planStatesAtom); - return (title: string) => states.get(title); + const states = get(planStatesAtom); + return (title: string) => states.get(title); }); /** * Helper atom to clear all plan states (useful when starting a new chat) */ export const clearPlanStatesAtom = atom(null, (get, set) => { - clearPlanOwnerRegistry(); - set(planStatesAtom, new Map()); + clearPlanOwnerRegistry(); + set(planStatesAtom, new Map()); }); /** @@ -151,84 +148,80 @@ export const clearPlanStatesAtom = atom(null, (get, set) => { * Call this when loading messages from the database to restore plan state */ export interface HydratePlanInput { - toolCallId: string; - result: { - id?: string; - title?: string; - description?: string; - todos?: Array<{ - id: string; - label: string; - status: "pending" | "in_progress" | "completed" | "cancelled"; - description?: string; - }>; - }; + toolCallId: string; + result: { + id?: string; + title?: string; + description?: string; + todos?: Array<{ + id: string; + label: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + description?: string; + }>; + }; } -export const hydratePlanStateAtom = atom( - null, - (get, set, plan: HydratePlanInput) => { - if (!plan.result?.todos || plan.result.todos.length === 0) return; - - const states = new Map(get(planStatesAtom)); - const title = plan.result.title || "Planning Approach"; - - // Register this as the owner if no plan exists yet - registerPlanOwner(title, plan.toolCallId); - - // Get the canonical title - const canonicalTitle = getCanonicalPlanTitle(title); - const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId; - - // Only set if this is newer or doesn't exist - const existing = states.get(canonicalTitle); - if (!existing) { - states.set(canonicalTitle, { - id: plan.result.id || `plan-${Date.now()}`, - title: canonicalTitle, - description: plan.result.description, - todos: plan.result.todos, - lastUpdated: Date.now(), - ownerToolCallId, - }); - set(planStatesAtom, states); - } - } -); +export const hydratePlanStateAtom = atom(null, (get, set, plan: HydratePlanInput) => { + if (!plan.result?.todos || plan.result.todos.length === 0) return; + + const states = new Map(get(planStatesAtom)); + const title = plan.result.title || "Planning Approach"; + + // Register this as the owner if no plan exists yet + registerPlanOwner(title, plan.toolCallId); + + // Get the canonical title + const canonicalTitle = getCanonicalPlanTitle(title); + const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId; + + // Only set if this is newer or doesn't exist + const existing = states.get(canonicalTitle); + if (!existing) { + states.set(canonicalTitle, { + id: plan.result.id || `plan-${Date.now()}`, + title: canonicalTitle, + description: plan.result.description, + todos: plan.result.todos, + lastUpdated: Date.now(), + ownerToolCallId, + }); + set(planStatesAtom, states); + } +}); /** * Extract write_todos tool call data from message content * Returns an array of { toolCallId, result } for each write_todos call found */ export function extractWriteTodosFromContent(content: unknown): HydratePlanInput[] { - if (!Array.isArray(content)) return []; - - const results: HydratePlanInput[] = []; - - for (const part of content) { - if ( - typeof part === "object" && - part !== null && - "type" in part && - (part as { type: string }).type === "tool-call" && - "toolName" in part && - (part as { toolName: string }).toolName === "write_todos" && - "toolCallId" in part && - "result" in part - ) { - const toolCall = part as { - toolCallId: string; - result: HydratePlanInput["result"]; - }; - if (toolCall.result) { - results.push({ - toolCallId: toolCall.toolCallId, - result: toolCall.result, - }); - } - } - } - - return results; -} + if (!Array.isArray(content)) return []; + const results: HydratePlanInput[] = []; + + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + "type" in part && + (part as { type: string }).type === "tool-call" && + "toolName" in part && + (part as { toolName: string }).toolName === "write_todos" && + "toolCallId" in part && + "result" in part + ) { + const toolCall = part as { + toolCallId: string; + result: HydratePlanInput["result"]; + }; + if (toolCall.result) { + results.push({ + toolCallId: toolCall.toolCallId, + result: toolCall.result, + }); + } + } + } + + return results; +} diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 04b2f28bd..23dc0bbad 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -910,7 +910,7 @@ const AssistantMessageInner: FC = () => {
-
+
diff --git a/surfsense_web/components/prompt-kit/loader.tsx b/surfsense_web/components/prompt-kit/loader.tsx index a51876e64..435a6a136 100644 --- a/surfsense_web/components/prompt-kit/loader.tsx +++ b/surfsense_web/components/prompt-kit/loader.tsx @@ -3,16 +3,16 @@ import { cn } from "@/lib/utils"; export interface LoaderProps { - variant?: "text-shimmer"; - size?: "sm" | "md" | "lg"; - text?: string; - className?: string; + variant?: "text-shimmer"; + size?: "sm" | "md" | "lg"; + text?: string; + className?: string; } const textSizes = { - sm: "text-xs", - md: "text-sm", - lg: "text-base", + sm: "text-xs", + md: "text-sm", + lg: "text-base", } as const; /** @@ -20,55 +20,47 @@ const textSizes = { * Used for in-progress states in write_todos and chain-of-thought */ export function TextShimmerLoader({ - text = "Thinking", - className, - size = "md", + text = "Thinking", + className, + size = "md", }: { - text?: string; - className?: string; - size?: "sm" | "md" | "lg"; + text?: string; + className?: string; + size?: "sm" | "md" | "lg"; }) { - return ( - <> - - - {text} - - - ); + + + {text} + + + ); } /** * Loader component - currently only supports text-shimmer variant * Can be extended with more variants if needed in the future */ -export function Loader({ - variant = "text-shimmer", - size = "md", - text, - className, -}: LoaderProps) { - switch (variant) { - case "text-shimmer": - default: - return ( - - ); - } +export function Loader({ variant = "text-shimmer", size = "md", text, className }: LoaderProps) { + switch (variant) { + case "text-shimmer": + default: + return ; + } } - diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 0ee0bb230..f5146c427 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { Trash2 } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; @@ -50,7 +50,13 @@ export function AppSidebarProvider({ const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); const router = useRouter(); + const params = useParams(); const queryClient = useQueryClient(); + + // Get current chat ID from URL params + const currentChatId = params?.chat_id + ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) + : null; const [isDeletingThread, setIsDeletingThread] = useState(false); // Editor state for handling unsaved changes @@ -61,7 +67,6 @@ export function AppSidebarProvider({ const { data: threadsData, error: threadError, - isLoading: isLoadingThreads, refetch: refetchThreads, } = useQuery({ queryKey: ["threads", searchSpaceId], @@ -73,7 +78,6 @@ export function AppSidebarProvider({ data: searchSpace, isLoading: isLoadingSearchSpace, error: searchSpaceError, - refetch: fetchSearchSpace, } = useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), @@ -83,12 +87,7 @@ export function AppSidebarProvider({ const { data: user } = useAtomValue(currentUserAtom); // Fetch notes - const { - data: notesData, - error: notesError, - isLoading: isLoadingNotes, - refetch: refetchNotes, - } = useQuery({ + const { data: notesData, refetch: refetchNotes } = useQuery({ queryKey: ["notes", searchSpaceId], queryFn: () => notesApiService.getNotes({ @@ -108,11 +107,6 @@ export function AppSidebarProvider({ } | null>(null); const [isDeletingNote, setIsDeletingNote] = useState(false); - // Retry function - const retryFetch = useCallback(() => { - fetchSearchSpace(); - }, [fetchSearchSpace]); - // Transform threads to the format expected by AppSidebar const recentChats = useMemo(() => { if (!threadsData?.threads) return []; @@ -149,8 +143,10 @@ export function AppSidebarProvider({ await deleteThread(threadToDelete.id); // Invalidate threads query to refresh the list queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); - // Navigate to new-chat after successful deletion - router.push(`/dashboard/${searchSpaceId}/new-chat`); + // Only navigate to new-chat if the deleted chat is currently open + if (currentChatId === threadToDelete.id) { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + } } catch (error) { console.error("Error deleting thread:", error); } finally { @@ -158,7 +154,7 @@ export function AppSidebarProvider({ setShowDeleteDialog(false); setThreadToDelete(null); } - }, [threadToDelete, queryClient, searchSpaceId, router]); + }, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]); // Handle delete note with confirmation const handleDeleteNote = useCallback(async () => { diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx index 333cd496c..660e95bca 100644 --- a/surfsense_web/components/tool-ui/display-image.tsx +++ b/surfsense_web/components/tool-ui/display-image.tsx @@ -2,6 +2,7 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { AlertCircleIcon, ImageIcon } from "lucide-react"; +import { z } from "zod"; import { Image, ImageErrorBoundary, @@ -9,27 +10,41 @@ import { parseSerializableImage, } from "@/components/tool-ui/image"; -/** - * Type definitions for the display_image tool - */ -interface DisplayImageArgs { - src: string; - alt?: string; - title?: string; - description?: string; -} +// ============================================================================ +// Zod Schemas +// ============================================================================ -interface DisplayImageResult { - id: string; - assetId: string; - src: string; - alt?: string; // Made optional - parseSerializableImage provides fallback - title?: string; - description?: string; - domain?: string; - ratio?: string; - error?: string; -} +/** + * Schema for display_image tool arguments + */ +const DisplayImageArgsSchema = z.object({ + src: z.string(), + alt: z.string().nullish(), + title: z.string().nullish(), + description: z.string().nullish(), +}); + +/** + * Schema for display_image tool result + */ +const DisplayImageResultSchema = z.object({ + id: z.string(), + assetId: z.string(), + src: z.string(), + alt: z.string().nullish(), + title: z.string().nullish(), + description: z.string().nullish(), + domain: z.string().nullish(), + ratio: z.string().nullish(), + error: z.string().nullish(), +}); + +// ============================================================================ +// Types +// ============================================================================ + +type DisplayImageArgs = z.infer; +type DisplayImageResult = z.infer; /** * Error state component shown when image display fails @@ -142,4 +157,9 @@ export const DisplayImageToolUI = makeAssistantToolUI; +type LinkPreviewResult = z.infer; /** * Error state component shown when link preview fails @@ -150,20 +165,35 @@ export const LinkPreviewToolUI = makeAssistantToolUI; +type MultiLinkPreviewResult = z.infer; export const MultiLinkPreviewToolUI = makeAssistantToolUI< MultiLinkPreviewArgs, @@ -217,4 +247,13 @@ export const MultiLinkPreviewToolUI = makeAssistantToolUI< }, }); -export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult }; +export { + LinkPreviewArgsSchema, + LinkPreviewResultSchema, + MultiLinkPreviewArgsSchema, + MultiLinkPreviewResultSchema, + type LinkPreviewArgs, + type LinkPreviewResult, + type MultiLinkPreviewArgs, + type MultiLinkPreviewResult, +}; diff --git a/surfsense_web/components/tool-ui/plan/index.tsx b/surfsense_web/components/tool-ui/plan/index.tsx index 989daede9..8467b0af9 100644 --- a/surfsense_web/components/tool-ui/plan/index.tsx +++ b/surfsense_web/components/tool-ui/plan/index.tsx @@ -11,43 +11,42 @@ export * from "./schema"; // ============================================================================ interface PlanErrorBoundaryProps { - children: ReactNode; - fallback?: ReactNode; + children: ReactNode; + fallback?: ReactNode; } interface PlanErrorBoundaryState { - hasError: boolean; - error?: Error; + hasError: boolean; + error?: Error; } export class PlanErrorBoundary extends Component { - constructor(props: PlanErrorBoundaryProps) { - super(props); - this.state = { hasError: false }; - } + constructor(props: PlanErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } - static getDerivedStateFromError(error: Error): PlanErrorBoundaryState { - return { hasError: true, error }; - } + static getDerivedStateFromError(error: Error): PlanErrorBoundaryState { + return { hasError: true, error }; + } - render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback; - } + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } - return ( - - -
- Failed to render plan -
-
-
- ); - } + return ( + + +
+ Failed to render plan +
+
+
+ ); + } - return this.props.children; - } + return this.props.children; + } } - diff --git a/surfsense_web/components/tool-ui/plan/plan.tsx b/surfsense_web/components/tool-ui/plan/plan.tsx index 90ac5c614..169749356 100644 --- a/surfsense_web/components/tool-ui/plan/plan.tsx +++ b/surfsense_web/components/tool-ui/plan/plan.tsx @@ -1,19 +1,13 @@ "use client"; -import { - CheckCircle2, - Circle, - CircleDashed, - PartyPopper, - XCircle, -} from "lucide-react"; +import { CheckCircle2, Circle, CircleDashed, PartyPopper, XCircle } from "lucide-react"; import type { FC } from "react"; import { useMemo, useState } from "react"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -29,37 +23,33 @@ import type { PlanTodo, TodoStatus } from "./schema"; // ============================================================================ interface StatusIconProps { - status: TodoStatus; - className?: string; - /** When false, in_progress items show as static (no spinner) */ - isStreaming?: boolean; + status: TodoStatus; + className?: string; + /** When false, in_progress items show as static (no spinner) */ + isStreaming?: boolean; } const StatusIcon: FC = ({ status, className, isStreaming = true }) => { - const baseClass = cn("size-4 shrink-0", className); + const baseClass = cn("size-4 shrink-0", className); - switch (status) { - case "completed": - return ; - case "in_progress": - // Only animate the spinner if we're actively streaming - // When streaming is stopped, show as a static dashed circle - return ( - - ); - case "cancelled": - return ; - case "pending": - default: - return ; - } + switch (status) { + case "completed": + return ; + case "in_progress": + // Only animate the spinner if we're actively streaming + // When streaming is stopped, show as a static dashed circle + return ( + + ); + case "cancelled": + return ; + case "pending": + default: + return ; + } }; // ============================================================================ @@ -67,55 +57,50 @@ const StatusIcon: FC = ({ status, className, isStreaming = true // ============================================================================ interface TodoItemProps { - todo: PlanTodo; - /** When false, in_progress items show as static (no spinner/pulse) */ - isStreaming?: boolean; + todo: PlanTodo; + /** When false, in_progress items show as static (no spinner/pulse) */ + isStreaming?: boolean; } const TodoItem: FC = ({ todo, isStreaming = true }) => { - const isStrikethrough = todo.status === "completed" || todo.status === "cancelled"; - // Only show shimmer animation if streaming and in progress - const isShimmer = todo.status === "in_progress" && isStreaming; + const isStrikethrough = todo.status === "completed" || todo.status === "cancelled"; + // Only show shimmer animation if streaming and in progress + const isShimmer = todo.status === "in_progress" && isStreaming; - // Render the label with optional shimmer effect - const renderLabel = () => { - if (isShimmer) { - return ; - } - return ( - - {todo.label} - - ); - }; + // Render the label with optional shimmer effect + const renderLabel = () => { + if (isShimmer) { + return ; + } + return ( + + {todo.label} + + ); + }; - if (todo.description) { - return ( - - -
- - {renderLabel()} -
-
- -

{todo.description}

-
-
- ); - } + if (todo.description) { + return ( + + +
+ + {renderLabel()} +
+
+ +

{todo.description}

+
+
+ ); + } - return ( -
- - {renderLabel()} -
- ); + return ( +
+ + {renderLabel()} +
+ ); }; // ============================================================================ @@ -123,159 +108,158 @@ const TodoItem: FC = ({ todo, isStreaming = true }) => { // ============================================================================ export interface PlanProps { - id: string; - title: string; - description?: string; - todos: PlanTodo[]; - maxVisibleTodos?: number; - showProgress?: boolean; - /** When false, in_progress items show as static (no spinner/pulse animations) */ - isStreaming?: boolean; - responseActions?: Action[] | ActionsConfig; - className?: string; - onResponseAction?: (actionId: string) => void; - onBeforeResponseAction?: (actionId: string) => boolean; + id: string; + title: string; + description?: string; + todos: PlanTodo[]; + maxVisibleTodos?: number; + showProgress?: boolean; + /** When false, in_progress items show as static (no spinner/pulse animations) */ + isStreaming?: boolean; + responseActions?: Action[] | ActionsConfig; + className?: string; + onResponseAction?: (actionId: string) => void; + onBeforeResponseAction?: (actionId: string) => boolean; } export const Plan: FC = ({ - id, - title, - description, - todos, - maxVisibleTodos = 4, - showProgress = true, - isStreaming = true, - responseActions, - className, - onResponseAction, - onBeforeResponseAction, + id, + title, + description, + todos, + maxVisibleTodos = 4, + showProgress = true, + isStreaming = true, + responseActions, + className, + onResponseAction, + onBeforeResponseAction, }) => { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); - // Calculate progress - const progress = useMemo(() => { - const completed = todos.filter((t) => t.status === "completed").length; - const total = todos.filter((t) => t.status !== "cancelled").length; - return { completed, total, percentage: total > 0 ? (completed / total) * 100 : 0 }; - }, [todos]); + // Calculate progress + const progress = useMemo(() => { + const completed = todos.filter((t) => t.status === "completed").length; + const total = todos.filter((t) => t.status !== "cancelled").length; + return { completed, total, percentage: total > 0 ? (completed / total) * 100 : 0 }; + }, [todos]); - const isAllComplete = progress.completed === progress.total && progress.total > 0; + const isAllComplete = progress.completed === progress.total && progress.total > 0; - // Split todos for collapsible display - const visibleTodos = todos.slice(0, maxVisibleTodos); - const hiddenTodos = todos.slice(maxVisibleTodos); - const hasHiddenTodos = hiddenTodos.length > 0; + // Split todos for collapsible display + const visibleTodos = todos.slice(0, maxVisibleTodos); + const hiddenTodos = todos.slice(maxVisibleTodos); + const hasHiddenTodos = hiddenTodos.length > 0; - // Check if any todo has a description (for accordion mode) - const hasDescriptions = todos.some((t) => t.description); + // Check if any todo has a description (for accordion mode) + const hasDescriptions = todos.some((t) => t.description); - // Handle action click - const handleAction = (actionId: string) => { - if (onBeforeResponseAction && !onBeforeResponseAction(actionId)) { - return; - } - onResponseAction?.(actionId); - }; + // Handle action click + const handleAction = (actionId: string) => { + if (onBeforeResponseAction && !onBeforeResponseAction(actionId)) { + return; + } + onResponseAction?.(actionId); + }; - // Normalize actions to array - const actionArray: Action[] = useMemo(() => { - if (!responseActions) return []; - if (Array.isArray(responseActions)) return responseActions; - return [ - responseActions.confirm && { ...responseActions.confirm, id: "confirm" }, - responseActions.cancel && { ...responseActions.cancel, id: "cancel" }, - ].filter(Boolean) as Action[]; - }, [responseActions]); + // Normalize actions to array + const actionArray: Action[] = useMemo(() => { + if (!responseActions) return []; + if (Array.isArray(responseActions)) return responseActions; + return [ + responseActions.confirm && { ...responseActions.confirm, id: "confirm" }, + responseActions.cancel && { ...responseActions.cancel, id: "cancel" }, + ].filter(Boolean) as Action[]; + }, [responseActions]); - const TodoList: FC<{ items: PlanTodo[] }> = ({ items }) => { - if (hasDescriptions) { - return ( - - {items.map((todo) => ( - - ))} - - ); - } + const TodoList: FC<{ items: PlanTodo[] }> = ({ items }) => { + if (hasDescriptions) { + return ( + + {items.map((todo) => ( + + ))} + + ); + } - return ( -
- {items.map((todo) => ( - - ))} -
- ); - }; + return ( +
+ {items.map((todo) => ( + + ))} +
+ ); + }; - return ( - - -
-
- {title} - {description && ( - {description} - )} -
- {isAllComplete && ( -
- -
- )} -
+ return ( + + +
+
+ {title} + {description && ( + {description} + )} +
+ {isAllComplete && ( +
+ +
+ )} +
- {showProgress && ( -
-
- - {progress.completed} of {progress.total} complete - - {Math.round(progress.percentage)}% -
- -
- )} -
+ {showProgress && ( +
+
+ + {progress.completed} of {progress.total} complete + + {Math.round(progress.percentage)}% +
+ +
+ )} +
- - + + - {hasHiddenTodos && ( - - - - - - - - - )} + {hasHiddenTodos && ( + + + + + + + + + )} - {actionArray.length > 0 && ( -
- {actionArray.map((action) => ( - - ))} -
- )} -
-
- ); + {actionArray.length > 0 && ( +
+ {actionArray.map((action) => ( + + ))} +
+ )} + + + ); }; - diff --git a/surfsense_web/components/tool-ui/plan/schema.ts b/surfsense_web/components/tool-ui/plan/schema.ts index e72233d03..fed49128a 100644 --- a/surfsense_web/components/tool-ui/plan/schema.ts +++ b/surfsense_web/components/tool-ui/plan/schema.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { ActionSchema } from "../shared/schema"; /** * Todo item status @@ -11,10 +10,10 @@ export type TodoStatus = z.infer; * Single todo item in a plan */ export const PlanTodoSchema = z.object({ - id: z.string(), - label: z.string(), - status: TodoStatusSchema, - description: z.string().optional(), + id: z.string(), + label: z.string(), + status: TodoStatusSchema, + description: z.string().optional(), }); export type PlanTodo = z.infer; @@ -23,12 +22,12 @@ export type PlanTodo = z.infer; * Serializable plan schema for tool results */ export const SerializablePlanSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string().optional(), - todos: z.array(PlanTodoSchema).min(1), - maxVisibleTodos: z.number().optional(), - showProgress: z.boolean().optional(), + id: z.string(), + title: z.string(), + description: z.string().optional(), + todos: z.array(PlanTodoSchema).min(1), + maxVisibleTodos: z.number().optional(), + showProgress: z.boolean().optional(), }); export type SerializablePlan = z.infer; @@ -37,31 +36,31 @@ export type SerializablePlan = z.infer; * Parse and validate a serializable plan from tool result */ export function parseSerializablePlan(data: unknown): SerializablePlan { - const result = SerializablePlanSchema.safeParse(data); - - if (!result.success) { - console.warn("Invalid plan data:", result.error.issues); - - // Try to extract basic info for fallback - const obj = (data && typeof data === "object" ? data : {}) as Record; - - return { - id: typeof obj.id === "string" ? obj.id : "unknown", - title: typeof obj.title === "string" ? obj.title : "Plan", - description: typeof obj.description === "string" ? obj.description : undefined, - todos: Array.isArray(obj.todos) - ? obj.todos.map((t, i) => ({ - id: typeof (t as any)?.id === "string" ? (t as any).id : `todo-${i}`, - label: typeof (t as any)?.label === "string" ? (t as any).label : "Task", - status: TodoStatusSchema.safeParse((t as any)?.status).success - ? (t as any).status - : "pending", - description: typeof (t as any)?.description === "string" ? (t as any).description : undefined, - })) - : [{ id: "1", label: "No tasks", status: "pending" as const }], - }; - } - - return result.data; -} + const result = SerializablePlanSchema.safeParse(data); + if (!result.success) { + console.warn("Invalid plan data:", result.error.issues); + + // Try to extract basic info for fallback + const obj = (data && typeof data === "object" ? data : {}) as Record; + + return { + id: typeof obj.id === "string" ? obj.id : "unknown", + title: typeof obj.title === "string" ? obj.title : "Plan", + description: typeof obj.description === "string" ? obj.description : undefined, + todos: Array.isArray(obj.todos) + ? obj.todos.map((t, i) => ({ + id: typeof (t as any)?.id === "string" ? (t as any).id : `todo-${i}`, + label: typeof (t as any)?.label === "string" ? (t as any).label : "Task", + status: TodoStatusSchema.safeParse((t as any)?.status).success + ? (t as any).status + : "pending", + description: + typeof (t as any)?.description === "string" ? (t as any).description : undefined, + })) + : [{ id: "1", label: "No tasks", status: "pending" as const }], + }; + } + + return result.data; +} diff --git a/surfsense_web/components/tool-ui/scrape-webpage.tsx b/surfsense_web/components/tool-ui/scrape-webpage.tsx index 025328235..29e7094db 100644 --- a/surfsense_web/components/tool-ui/scrape-webpage.tsx +++ b/surfsense_web/components/tool-ui/scrape-webpage.tsx @@ -2,6 +2,7 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { AlertCircleIcon, FileTextIcon } from "lucide-react"; +import { z } from "zod"; import { Article, ArticleErrorBoundary, @@ -9,30 +10,44 @@ import { parseSerializableArticle, } from "@/components/tool-ui/article"; -/** - * Type definitions for the scrape_webpage tool - */ -interface ScrapeWebpageArgs { - url: string; - max_length?: number; -} +// ============================================================================ +// Zod Schemas +// ============================================================================ -interface ScrapeWebpageResult { - id: string; - assetId: string; - kind: "article"; - href: string; - title: string; - description?: string; - content?: string; - domain?: string; - author?: string; - date?: string; - word_count?: number; - was_truncated?: boolean; - crawler_type?: string; - error?: string; -} +/** + * Schema for scrape_webpage tool arguments + */ +const ScrapeWebpageArgsSchema = z.object({ + url: z.string(), + max_length: z.number().nullish(), +}); + +/** + * Schema for scrape_webpage tool result + */ +const ScrapeWebpageResultSchema = z.object({ + id: z.string(), + assetId: z.string(), + kind: z.literal("article"), + href: z.string(), + title: z.string(), + description: z.string().nullish(), + content: z.string().nullish(), + domain: z.string().nullish(), + author: z.string().nullish(), + date: z.string().nullish(), + word_count: z.number().nullish(), + was_truncated: z.boolean().nullish(), + crawler_type: z.string().nullish(), + error: z.string().nullish(), +}); + +// ============================================================================ +// Types +// ============================================================================ + +type ScrapeWebpageArgs = z.infer; +type ScrapeWebpageResult = z.infer; /** * Error state component shown when webpage scraping fails @@ -154,4 +169,9 @@ export const ScrapeWebpageToolUI = makeAssistantToolUI void; - disabled?: boolean; + actions?: Action[] | ActionsConfig; + onAction?: (actionId: string) => void; + disabled?: boolean; } export const ActionButtons: FC = ({ actions, onAction, disabled }) => { - if (!actions) return null; + if (!actions) return null; - // Normalize actions to array format - const actionArray: Action[] = Array.isArray(actions) - ? actions - : [ - actions.confirm && { ...actions.confirm, id: "confirm" }, - actions.cancel && { ...actions.cancel, id: "cancel" }, - ].filter(Boolean) as Action[]; + // Normalize actions to array format + const actionArray: Action[] = Array.isArray(actions) + ? actions + : ([ + actions.confirm && { ...actions.confirm, id: "confirm" }, + actions.cancel && { ...actions.cancel, id: "cancel" }, + ].filter(Boolean) as Action[]); - if (actionArray.length === 0) return null; + if (actionArray.length === 0) return null; - return ( -
- {actionArray.map((action) => ( - - ))} -
- ); + return ( +
+ {actionArray.map((action) => ( + + ))} +
+ ); }; - diff --git a/surfsense_web/components/tool-ui/shared/index.ts b/surfsense_web/components/tool-ui/shared/index.ts index fae3af451..1d1f1275e 100644 --- a/surfsense_web/components/tool-ui/shared/index.ts +++ b/surfsense_web/components/tool-ui/shared/index.ts @@ -1,3 +1,2 @@ export * from "./schema"; export * from "./action-buttons"; - diff --git a/surfsense_web/components/tool-ui/shared/schema.ts b/surfsense_web/components/tool-ui/shared/schema.ts index 71c2422b9..8076a8e45 100644 --- a/surfsense_web/components/tool-ui/shared/schema.ts +++ b/surfsense_web/components/tool-ui/shared/schema.ts @@ -4,10 +4,10 @@ import { z } from "zod"; * Shared action schema for tool UI components */ export const ActionSchema = z.object({ - id: z.string(), - label: z.string(), - variant: z.enum(["default", "secondary", "destructive", "outline", "ghost", "link"]).optional(), - disabled: z.boolean().optional(), + id: z.string(), + label: z.string(), + variant: z.enum(["default", "secondary", "destructive", "outline", "ghost", "link"]).optional(), + disabled: z.boolean().optional(), }); export type Action = z.infer; @@ -16,9 +16,8 @@ export type Action = z.infer; * Actions configuration schema */ export const ActionsConfigSchema = z.object({ - confirm: ActionSchema.optional(), - cancel: ActionSchema.optional(), + confirm: ActionSchema.optional(), + cancel: ActionSchema.optional(), }); export type ActionsConfig = z.infer; - diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx index 6d8e0446b..4d2a44dcb 100644 --- a/surfsense_web/components/tool-ui/write-todos.tsx +++ b/surfsense_web/components/tool-ui/write-todos.tsx @@ -4,54 +4,76 @@ import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { Loader2 } from "lucide-react"; import { useEffect, useMemo } from "react"; +import { z } from "zod"; import { - getCanonicalPlanTitle, - planStatesAtom, - registerPlanOwner, - updatePlanStateAtom, + getCanonicalPlanTitle, + planStatesAtom, + registerPlanOwner, + updatePlanStateAtom, } from "@/atoms/chat/plan-state.atom"; -import { Plan, PlanErrorBoundary, parseSerializablePlan } from "./plan"; +import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan"; + +// ============================================================================ +// Zod Schemas +// ============================================================================ /** - * Tool arguments for write_todos + * Schema for a single todo item in the args */ -interface WriteTodosArgs { - title?: string; - description?: string; - todos?: Array<{ - id: string; - content: string; - status: "pending" | "in_progress" | "completed" | "cancelled"; - }>; -} +const WriteTodosArgsTodoSchema = z.object({ + id: z.string(), + content: z.string(), + status: TodoStatusSchema, +}); /** - * Tool result for write_todos + * Schema for write_todos tool arguments */ -interface WriteTodosResult { - id: string; - title: string; - description?: string; - todos: Array<{ - id: string; - label: string; - status: "pending" | "in_progress" | "completed" | "cancelled"; - description?: string; - }>; -} +const WriteTodosArgsSchema = z.object({ + title: z.string().nullish(), + description: z.string().nullish(), + todos: z.array(WriteTodosArgsTodoSchema).nullish(), +}); + +/** + * Schema for a single todo item in the result + */ +const WriteTodosResultTodoSchema = z.object({ + id: z.string(), + label: z.string(), + status: TodoStatusSchema, + description: z.string().nullish(), +}); + +/** + * Schema for write_todos tool result + */ +const WriteTodosResultSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullish(), + todos: z.array(WriteTodosResultTodoSchema), +}); + +// ============================================================================ +// Types +// ============================================================================ + +type WriteTodosArgs = z.infer; +type WriteTodosResult = z.infer; /** * Loading state component */ function WriteTodosLoading() { - return ( -
-
- - Creating plan... -
-
- ); + return ( +
+
+ + Creating plan... +
+
+ ); } /** @@ -59,20 +81,20 @@ function WriteTodosLoading() { * This handles the case where the LLM is streaming the tool call */ function transformArgsToResult(args: WriteTodosArgs): WriteTodosResult | null { - if (!args.todos || !Array.isArray(args.todos) || args.todos.length === 0) { - return null; - } + if (!args.todos || !Array.isArray(args.todos) || args.todos.length === 0) { + return null; + } - return { - id: `plan-${Date.now()}`, - title: args.title || "Planning Approach", - description: args.description, - todos: args.todos.map((todo, index) => ({ - id: todo.id || `todo-${index}`, - label: todo.content || "Task", - status: todo.status || "pending", - })), - }; + return { + id: `plan-${Date.now()}`, + title: args.title || "Planning Approach", + description: args.description, + todos: args.todos.map((todo, index) => ({ + id: todo.id || `todo-${index}`, + label: todo.content || "Task", + status: todo.status || "pending", + })), + }; } /** @@ -87,116 +109,115 @@ function transformArgsToResult(args: WriteTodosArgs): WriteTodosResult | null { * layout shift when plans are updated. */ export const WriteTodosToolUI = makeAssistantToolUI({ - toolName: "write_todos", - render: function WriteTodosUI({ args, result, status, toolCallId }) { - const updatePlanState = useSetAtom(updatePlanStateAtom); - const planStates = useAtomValue(planStatesAtom); - - // Check if the THREAD is running (not just this tool) - // This hook subscribes to state changes, so it re-renders when thread stops - const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + toolName: "write_todos", + render: function WriteTodosUI({ args, result, status, toolCallId }) { + const updatePlanState = useSetAtom(updatePlanStateAtom); + const planStates = useAtomValue(planStatesAtom); - // Get the plan data (from result or args) - const planData = result || transformArgsToResult(args); - const rawTitle = planData?.title || args.title || "Planning Approach"; + // Check if the THREAD is running (not just this tool) + // This hook subscribes to state changes, so it re-renders when thread stops + const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); - // SYNCHRONOUS ownership check - happens immediately, no race conditions - // ONE PLAN PER CONVERSATION: Only first write_todos call becomes owner - const isOwner = useMemo(() => { - return registerPlanOwner(rawTitle, toolCallId); - }, [rawTitle, toolCallId]); - - // Get canonical title - always use the FIRST plan's title - // This ensures all updates go to the same plan state - const planTitle = useMemo(() => getCanonicalPlanTitle(rawTitle), [rawTitle]); + // Get the plan data (from result or args) + const planData = result || transformArgsToResult(args); + const rawTitle = planData?.title || args.title || "Planning Approach"; - // Register/update the plan state - ALWAYS use canonical title - useEffect(() => { - if (planData) { - updatePlanState({ - id: planData.id, - title: planTitle, // Use canonical title, not raw title - description: planData.description, - todos: planData.todos, - toolCallId, - }); - } - }, [planData, planTitle, updatePlanState, toolCallId]); + // SYNCHRONOUS ownership check - happens immediately, no race conditions + // ONE PLAN PER CONVERSATION: Only first write_todos call becomes owner + const isOwner = useMemo(() => { + return registerPlanOwner(rawTitle, toolCallId); + }, [rawTitle, toolCallId]); - // Update when result changes (for streaming updates) - useEffect(() => { - if (result) { - updatePlanState({ - id: result.id, - title: planTitle, // Use canonical title, not raw title - description: result.description, - todos: result.todos, - toolCallId, - }); - } - }, [result, planTitle, updatePlanState, toolCallId]); + // Get canonical title - always use the FIRST plan's title + // This ensures all updates go to the same plan state + const planTitle = useMemo(() => getCanonicalPlanTitle(rawTitle), [rawTitle]); - // Get the current plan state (may be updated by other components) - const currentPlanState = planStates.get(planTitle); + // Register/update the plan state - ALWAYS use canonical title + useEffect(() => { + if (planData) { + updatePlanState({ + id: planData.id, + title: planTitle, // Use canonical title, not raw title + description: planData.description, + todos: planData.todos, + toolCallId, + }); + } + }, [planData, planTitle, updatePlanState, toolCallId]); - // If we're NOT the owner, render nothing (the owner will render) - if (!isOwner) { - return null; - } + // Update when result changes (for streaming updates) + useEffect(() => { + if (result) { + updatePlanState({ + id: result.id, + title: planTitle, // Use canonical title, not raw title + description: result.description, + todos: result.todos, + toolCallId, + }); + } + }, [result, planTitle, updatePlanState, toolCallId]); - // Loading state - tool is still running (no data yet) - if (status.type === "running" || status.type === "requires-action") { - // Try to show partial results from args while streaming - const partialResult = transformArgsToResult(args); - if (partialResult) { - const plan = parseSerializablePlan(partialResult); - return ( -
- - - -
- ); - } - return ; - } + // Get the current plan state (may be updated by other components) + const currentPlanState = planStates.get(planTitle); - // Incomplete/cancelled state - if (status.type === "incomplete") { - // For cancelled or errors, try to show what we have from args or shared state - // Use isThreadRunning to determine if we should still animate - const fallbackResult = currentPlanState || transformArgsToResult(args); - if (fallbackResult) { - const plan = parseSerializablePlan(fallbackResult); - return ( -
- - - -
- ); - } - return null; - } + // If we're NOT the owner, render nothing (the owner will render) + if (!isOwner) { + return null; + } - // Success - render the plan using the LATEST shared state - // Use isThreadRunning to determine if we should animate in_progress items - // (LLM may still be working on tasks even though this tool call completed) - const planToRender = currentPlanState || result; - if (!planToRender) { - return ; - } - - const plan = parseSerializablePlan(planToRender); - return ( -
- - - -
- ); - }, + // Loading state - tool is still running (no data yet) + if (status.type === "running" || status.type === "requires-action") { + // Try to show partial results from args while streaming + const partialResult = transformArgsToResult(args); + if (partialResult) { + const plan = parseSerializablePlan(partialResult); + return ( +
+ + + +
+ ); + } + return ; + } + + // Incomplete/cancelled state + if (status.type === "incomplete") { + // For cancelled or errors, try to show what we have from args or shared state + // Use isThreadRunning to determine if we should still animate + const fallbackResult = currentPlanState || transformArgsToResult(args); + if (fallbackResult) { + const plan = parseSerializablePlan(fallbackResult); + return ( +
+ + + +
+ ); + } + return null; + } + + // Success - render the plan using the LATEST shared state + // Use isThreadRunning to determine if we should animate in_progress items + // (LLM may still be working on tasks even though this tool call completed) + const planToRender = currentPlanState || result; + if (!planToRender) { + return ; + } + + const plan = parseSerializablePlan(planToRender); + return ( +
+ + + +
+ ); + }, }); -export type { WriteTodosArgs, WriteTodosResult }; - +export { WriteTodosArgsSchema, WriteTodosResultSchema, type WriteTodosArgs, type WriteTodosResult }; From 8a3ab3dfaca1585fca9c0aa049e7339b1b7a0c0e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 19:24:32 +0530 Subject: [PATCH 11/24] refactor: enhance write_todos tool and system prompt - Updated the write_todos tool to include an optional description field for todo items, improving task detail management. - Enhanced the system prompt with clearer guidelines on using the write_todos tool, including refined usage patterns and examples for various user scenarios. - Improved UI components to support the new description feature, ensuring better visibility of task details during planning and execution. - Streamlined the code for better readability and maintainability, aligning with recent refactoring efforts. --- .../app/agents/new_chat/system_prompt.py | 123 ++------- .../app/agents/new_chat/tools/write_todos.py | 26 +- .../components/assistant-ui/thread.tsx | 247 +++++++++--------- .../components/tool-ui/plan/plan.tsx | 7 +- .../components/tool-ui/write-todos.tsx | 29 +- 5 files changed, 197 insertions(+), 235 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 24d42d60c..54fa77c2e 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -111,27 +111,13 @@ You have access to the following tools: * Don't show every image - just the most relevant 1-3 images that enhance understanding. 6. write_todos: Create and update a planning/todo list to break down complex tasks. - - - STRICT USAGE CRITERIA - MUST MEET ALL CONDITIONS: - * Condition 1: User EXPLICITLY requests structured output using trigger words (see below) - * Condition 2: Task requires 4+ DISTINCT phases/steps to achieve - * Condition 3: Task involves CREATING, BUILDING, ACHIEVING, or PRODUCING something - - - VALID TRIGGER WORDS/PHRASES (user must use one of these): - * "plan" / "make a plan" / "create a plan" / "planning" - * "roadmap" / "create a roadmap" - * "step-by-step" / "step by step" - * "break down" / "breakdown" - * "walk me through" - * "guide me through" - * "how should I approach" - * "help me create" / "help me build" / "help me achieve" + - Use this tool when you need to plan your approach to a complex task. + - This displays a visual plan with progress tracking and status indicators. - USAGE PATTERN: * First call: Create the plan with first task as "in_progress", rest as "pending" * Subsequent calls: ONLY update task statuses (mark completed/in_progress) * Use the EXACT SAME title and task IDs for all updates - * ONLY ONE PLAN PER CONVERSATION - never create a second plan - ABSOLUTELY FORBIDDEN - WILL BREAK THE SYSTEM: * ONLY ONE PLAN PER CONVERSATION - NEVER call write_todos a second time to create a new plan @@ -146,63 +132,29 @@ You have access to the following tools: * Do NOT use phrases like "This report is based on..." or "Based on my research..." * Just answer the question directly - do not roleplay producing a deliverable + - CORRECT BEHAVIOR: + * Call write_todos to update statuses as you progress + * Each section of your response appears EXACTLY ONCE + * When you finish explaining all tasks, your response is COMPLETE + * Do NOT generate additional content after concluding + - CONTENT QUALITY: * Provide thorough, detailed explanations for each task * The restriction is on DUPLICATING content, not on depth or detail * Each task deserves a complete, comprehensive explanation * Be as detailed as needed - just don't repeat yourself - - VALID USE CASES BY USER TYPE: - - RESEARCHERS/STUDENTS: - * "Help me plan my thesis research on X" - has "plan" + multi-phase project - * "Create a roadmap for my dissertation" - has "roadmap" + structured work - * "Break down my literature review process" - has "break down" + phases - - WRITERS/CONTENT CREATORS: - * "Help me plan my book outline" - has "plan" + creative project - * "Walk me through writing a research paper" - has trigger + structured work - - BUSINESS/PROFESSIONALS: - * "Create a plan for launching my product" - has "plan" + business goal - * "Break down the hiring process for my team" - has "break down" + phases - - PERSONAL/LIFESTYLE: - * "Help me plan my career transition" - has "plan" + life goal - * "Create a roadmap for learning a new skill" - has "roadmap" + phases - - TECHNICAL: - * "Help me plan implementing authentication" - has "plan" + implementation - * "Create a roadmap for this API" - has "roadmap" + technical project - - - ABSOLUTELY DO NOT USE FOR (even if task seems complex): - * Simple questions: "What is X?", "How does Y work?", "Explain Z" - * Summaries: "Summarize this", "Key points of", "Overview of" - * Document explanations: "Explain this PDF", "What does this article say" - * Comparisons: "Compare X and Y", "Difference between" - * Searches/Lookups: "Find X", "Search for Y", "What did I save about" - * Quick recommendations: "What should I read about X" - * Opinions/Analysis: "What do you think of", "Analyze this" - * Podcast generation, link previews, image display, single searches - * Any single-response task that does not require multiple phases - - - CRITICAL DISTINCTION: - * EXPLAINING something = NO write_todos (just explain directly) - * CREATING/PLANNING something = YES write_todos (if 4+ phases and trigger word used) - - - SELF-CHECK (must answer YES to ALL before using): - 1. Did the user use a valid trigger word from the list above? If NO -> DO NOT USE - 2. Is user asking to CREATE/PLAN/ACHIEVE something (not just explain)? If NO -> DO NOT USE - 3. Does this require 4+ distinct phases to complete? If NO -> DO NOT USE - 4. Would a direct response be faster and better for the user? If YES -> DO NOT USE - - - DEFAULT BEHAVIOR: When in doubt, DO NOT use write_todos. Fast responses beat unnecessary plans. + - When to use: + * Breaking down a complex multi-step task (3-5 tasks recommended) + * Showing the user what steps you'll take to solve their problem + * Creating an implementation roadmap - Args: - todos: List of todo items, each with: * id: Unique identifier (KEEP SAME IDs across updates) * content: Description of the task (KEEP SAME content across updates) * status: "pending", "in_progress", or "completed" + * description: Optional subtask/detail text shown when the item is expanded (e.g., "Analyzing document structure and key concepts") - title: Title for the plan (MUST BE IDENTICAL across all updates) - description: Optional context description @@ -275,54 +227,21 @@ You have access to the following tools: - Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")` - Then provide your explanation, referencing the displayed image -- User: "Help me plan implementing a user authentication system" - - Has trigger word "plan" + implementation task with 4+ phases -> USE write_todos +- User: "Help me implement a user authentication system" - Step 1: Create plan with task 1 in_progress: - `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "in_progress"}, {"id": "2", "content": "Set up password hashing", "status": "pending"}, {"id": "3", "content": "Create endpoints", "status": "pending"}, {"id": "4", "content": "Add session management", "status": "pending"}])` + `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "in_progress"}, {"id": "2", "content": "Set up password hashing", "status": "pending"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` - Step 2: Provide DETAILED explanation of database schema design - Step 3: Update plan (task 1 done, task 2 in_progress): - `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "completed"}, {"id": "2", "content": "Set up password hashing", "status": "in_progress"}, {"id": "3", "content": "Create endpoints", "status": "pending"}, {"id": "4", "content": "Add session management", "status": "pending"}])` + `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "completed"}, {"id": "2", "content": "Set up password hashing", "status": "in_progress"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` - Step 4: Provide DETAILED explanation of password hashing (NEW content only) - - Step 5: Continue updating plan and explaining each task + - Step 5: Update plan, explain endpoints in detail - Step 6: Mark all complete, END response - DO NOT restart or regenerate - FORBIDDEN: Do not go back and explain schema again after step 2 -- User: "Create a roadmap for my thesis research" - - Has trigger word "roadmap" + multi-phase project -> USE write_todos - - Create plan with 4+ research phases, update as you explain each - -- User: "Walk me through building a marketing campaign step-by-step" - - Has trigger words "walk me through" and "step-by-step" + creation task -> USE write_todos - -EXAMPLES OF WHEN NOT TO USE write_todos: - -- User: "Explain this PDF document" - - NO trigger word, EXPLAINING not creating -> DO NOT use write_todos - - Just explain the document content directly - -- User: "What is machine learning?" - - Simple question, NO trigger word -> DO NOT use write_todos - - Just answer directly - -- User: "Summarize this article for me" - - Summary request, NO trigger word -> DO NOT use write_todos - - Just provide the summary directly - -- User: "Compare React and Vue" - - Comparison request, NO trigger word -> DO NOT use write_todos - - Just compare them directly - -- User: "What did I discuss on Slack last week?" - - Search request, NO trigger word -> DO NOT use write_todos - - Just search and present results - -- User: "Give me an overview of this research paper" - - Explanation request, NO trigger word -> DO NOT use write_todos - - Just provide the overview directly - -- User: "How does async/await work in JavaScript?" - - Explanation request, NO trigger word -> DO NOT use write_todos - - Just explain directly +- User: "How should I approach refactoring this large codebase?" + - Create plan, explain each step with thorough detail, update statuses as you go + - Each explanation is comprehensive but appears ONLY ONCE + - When finished with all tasks, STOP - do not continue generating """ diff --git a/surfsense_backend/app/agents/new_chat/tools/write_todos.py b/surfsense_backend/app/agents/new_chat/tools/write_todos.py index f747d891f..466c74997 100644 --- a/surfsense_backend/app/agents/new_chat/tools/write_todos.py +++ b/surfsense_backend/app/agents/new_chat/tools/write_todos.py @@ -40,6 +40,7 @@ def create_write_todos_tool(): - id: Unique identifier for the todo - content: Description of the task - status: One of "pending", "in_progress", "completed", "cancelled" + - description: Optional subtask/detail text shown when the item is expanded title: Title for the plan (default: "Planning Approach") description: Optional description providing context @@ -51,10 +52,10 @@ def create_write_todos_tool(): title="Implementation Plan", description="Steps to add the new feature", todos=[ - {"id": "1", "content": "Analyze requirements", "status": "completed"}, - {"id": "2", "content": "Design solution", "status": "in_progress"}, + {"id": "1", "content": "Analyze requirements", "status": "completed", "description": "Reviewed all user stories and acceptance criteria"}, + {"id": "2", "content": "Design solution", "status": "in_progress", "description": "Creating component architecture and data flow diagrams"}, {"id": "3", "content": "Write code", "status": "pending"}, - {"id": "4", "content": "Add tests", "status": "pending"}, + {"id": "4", "content": "Add tests", "status": "pending", "description": "Unit tests and integration tests for all new components"}, ] ) """ @@ -69,19 +70,24 @@ def create_write_todos_tool(): todo_id = todo.get("id", f"todo-{i}") content = todo.get("content", "") status = todo.get("status", "pending") + todo_description = todo.get("description") # Validate status valid_statuses = ["pending", "in_progress", "completed", "cancelled"] if status not in valid_statuses: status = "pending" - formatted_todos.append( - { - "id": todo_id, - "label": content, - "status": status, - } - ) + todo_item = { + "id": todo_id, + "label": content, + "status": status, + } + + # Only include description if provided + if todo_description: + todo_item["description"] = todo_description + + formatted_todos.append(todo_item) return { "id": plan_id, diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 23dc0bbad..de98dd667 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -15,8 +15,6 @@ import { AlertCircle, ArrowDownIcon, ArrowUpIcon, - Brain, - CheckCircle2, CheckIcon, ChevronLeftIcon, ChevronRightIcon, @@ -28,8 +26,6 @@ import { Plug2, Plus, RefreshCwIcon, - Search, - Sparkles, SquareIcon, } from "lucide-react"; import Link from "next/link"; @@ -75,13 +71,7 @@ import { DocumentMentionPicker, type DocumentMentionPickerRef, } from "@/components/new-chat/document-mention-picker"; -import { - ChainOfThought, - ChainOfThoughtContent, - ChainOfThoughtItem, - ChainOfThoughtStep, - ChainOfThoughtTrigger, -} from "@/components/prompt-kit/chain-of-thought"; +import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -103,124 +93,149 @@ interface ThreadProps { const ThinkingStepsContext = createContext>(new Map()); /** - * Get icon based on step status and title - */ -function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) { - const titleLower = title.toLowerCase(); - - if (status === "in_progress") { - return ; - } - - if (status === "completed") { - return ; - } - - if (titleLower.includes("search") || titleLower.includes("knowledge")) { - return ; - } - - if (titleLower.includes("analy") || titleLower.includes("understand")) { - return ; - } - - return ; -} - -/** - * Chain of thought display component with smart expand/collapse behavior + * Chain of thought display component - single collapsible dropdown design */ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({ steps, isThreadRunning = true, }) => { - // Track which steps the user has manually toggled (overrides auto behavior) - const [manualOverrides, setManualOverrides] = useState>({}); - // Track previous step statuses to detect changes - const prevStatusesRef = useRef>({}); + const [isOpen, setIsOpen] = useState(true); - // Derive effective status: if thread stopped and step is in_progress, treat as completed - const getEffectiveStatus = (step: ThinkingStep): "pending" | "in_progress" | "completed" => { - if (step.status === "in_progress" && !isThreadRunning) { - return "completed"; // Thread was stopped, so mark as completed - } - return step.status; - }; - - // Clear manual overrides when a step's status changes - useEffect(() => { - const currentStatuses: Record = {}; - steps.forEach((step) => { - currentStatuses[step.id] = step.status; - // If status changed, clear any manual override for this step - if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) { - setManualOverrides((prev) => { - const next = { ...prev }; - delete next[step.id]; - return next; - }); + // Derive effective status for each step + const getEffectiveStatus = useCallback( + (step: ThinkingStep): "pending" | "in_progress" | "completed" => { + if (step.status === "in_progress" && !isThreadRunning) { + return "completed"; } - }); - prevStatusesRef.current = currentStatuses; - }, [steps]); + return step.status; + }, + [isThreadRunning] + ); + + // Calculate summary info + const completedSteps = steps.filter((s) => getEffectiveStatus(s) === "completed").length; + const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress"); + const allCompleted = completedSteps === steps.length && steps.length > 0 && !isThreadRunning; + const isProcessing = isThreadRunning && !allCompleted; + + // Auto-collapse when all tasks are completed + useEffect(() => { + if (allCompleted) { + setIsOpen(false); + } + }, [allCompleted]); if (steps.length === 0) return null; - const getStepOpenState = (step: ThinkingStep): boolean => { - const effectiveStatus = getEffectiveStatus(step); - // If user has manually toggled, respect that - if (manualOverrides[step.id] !== undefined) { - return manualOverrides[step.id]; + // Generate header text + const getHeaderText = () => { + if (allCompleted) { + return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`; } - // Auto behavior: open if in progress - if (effectiveStatus === "in_progress") { - return true; + if (inProgressStep) { + return inProgressStep.title; } - // Default: collapsed (all steps collapse when processing is done) - return false; - }; - - const handleToggle = (stepId: string, currentOpen: boolean) => { - setManualOverrides((prev) => ({ - ...prev, - [stepId]: !currentOpen, - })); + if (isProcessing) { + return `Processing ${completedSteps}/${steps.length} steps`; + } + return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`; }; return (
- - {steps.map((step) => { - const effectiveStatus = getEffectiveStatus(step); - const icon = getStepIcon(effectiveStatus, step.title); - const isOpen = getStepOpenState(step); - return ( - handleToggle(step.id, isOpen)} - > - - {step.title} - - {step.items && step.items.length > 0 && ( - - {step.items.map((item, idx) => ( - {item} - ))} - - )} - - ); - })} - +
+ {/* Main collapsible header */} + + + {/* Collapsible content with CSS grid animation */} +
+
+
+ {steps.map((step, index) => { + const effectiveStatus = getEffectiveStatus(step); + const isLast = index === steps.length - 1; + + return ( +
+ {/* Dot and line column */} +
+ {/* Vertical connection line - extends to next dot */} + {!isLast && ( +
+ )} + {/* Step dot - on top of line */} +
+ {effectiveStatus === "in_progress" ? ( + + ) : ( + + )} +
+
+ + {/* Step content */} +
+ {/* Step title */} +
+ {effectiveStatus === "in_progress" ? ( + + ) : ( + step.title + )} +
+ + {/* Step items (sub-content) */} + {step.items && step.items.length > 0 && ( +
+ {step.items.map((item, idx) => ( +
+ {item} +
+ ))} +
+ )} +
+
+ ); + })} +
+
+
+
); }; @@ -676,14 +691,10 @@ const ConnectorIndicator: FC = () => { ) : ( <> - {totalSourceCount > 0 ? ( + {totalSourceCount > 0 && ( {totalSourceCount > 99 ? "99+" : totalSourceCount} - ) : ( - - - )} )} diff --git a/surfsense_web/components/tool-ui/plan/plan.tsx b/surfsense_web/components/tool-ui/plan/plan.tsx index 169749356..d99dbc1c6 100644 --- a/surfsense_web/components/tool-ui/plan/plan.tsx +++ b/surfsense_web/components/tool-ui/plan/plan.tsx @@ -172,10 +172,15 @@ export const Plan: FC = ({ ].filter(Boolean) as Action[]; }, [responseActions]); + // Get default expanded items (in_progress items with descriptions) + const defaultExpandedIds = useMemo(() => { + return todos.filter((t) => t.description && t.status === "in_progress").map((t) => t.id); + }, [todos]); + const TodoList: FC<{ items: PlanTodo[] }> = ({ items }) => { if (hasDescriptions) { return ( - + {items.map((todo) => ( ))} diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx index 4d2a44dcb..81e7ab978 100644 --- a/surfsense_web/components/tool-ui/write-todos.tsx +++ b/surfsense_web/components/tool-ui/write-todos.tsx @@ -19,39 +19,59 @@ import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from /** * Schema for a single todo item in the args + * Note: Using nullish() with transform to convert null → undefined for Plan compatibility */ const WriteTodosArgsTodoSchema = z.object({ id: z.string(), content: z.string(), status: TodoStatusSchema, + description: z + .string() + .nullish() + .transform((v) => v ?? undefined), }); /** * Schema for write_todos tool arguments + * Note: Using nullish() with transform to convert null → undefined for Plan compatibility */ const WriteTodosArgsSchema = z.object({ - title: z.string().nullish(), - description: z.string().nullish(), + title: z + .string() + .nullish() + .transform((v) => v ?? undefined), + description: z + .string() + .nullish() + .transform((v) => v ?? undefined), todos: z.array(WriteTodosArgsTodoSchema).nullish(), }); /** * Schema for a single todo item in the result + * Note: Using nullish() with transform to convert null → undefined for Plan compatibility */ const WriteTodosResultTodoSchema = z.object({ id: z.string(), label: z.string(), status: TodoStatusSchema, - description: z.string().nullish(), + description: z + .string() + .nullish() + .transform((v) => v ?? undefined), }); /** * Schema for write_todos tool result + * Note: Using nullish() with transform to convert null → undefined for Plan compatibility */ const WriteTodosResultSchema = z.object({ id: z.string(), title: z.string(), - description: z.string().nullish(), + description: z + .string() + .nullish() + .transform((v) => v ?? undefined), todos: z.array(WriteTodosResultTodoSchema), }); @@ -93,6 +113,7 @@ function transformArgsToResult(args: WriteTodosArgs): WriteTodosResult | null { id: todo.id || `todo-${index}`, label: todo.content || "Task", status: todo.status || "pending", + description: todo.description, })), }; } From c28a90fc29f3cc5e1c21cb879e7bac1ab0220c87 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:18:34 +0530 Subject: [PATCH 12/24] refactor: integrate TodoListMiddleware and update related components - Removed the write_todos tool as it is now included by default through TodoListMiddleware in the deep agent. - Updated the system prompt and documentation to reflect the integration of TodoListMiddleware, clarifying its capabilities for managing planning and todo lists. - Enhanced the chat handling logic to extract todos directly from the deep agent's command output, ensuring seamless user experience. - Refactored UI components to align with the new data structure and improve rendering of todo items, including updates to the Plan and TodoItem components. - Cleaned up code for better maintainability and readability, following recent refactoring efforts. --- .../app/agents/new_chat/chat_deepagent.py | 4 + .../app/agents/new_chat/system_prompt.py | 85 ++------- .../app/agents/new_chat/tools/registry.py | 9 +- .../app/agents/new_chat/tools/write_todos.py | 99 ---------- .../app/tasks/chat/stream_new_chat.py | 37 +++- surfsense_web/atoms/chat/plan-state.atom.ts | 21 +-- surfsense_web/components/tool-ui/index.ts | 8 +- .../components/tool-ui/plan/plan.tsx | 88 +++------ .../components/tool-ui/plan/schema.ts | 67 ++++--- .../components/tool-ui/write-todos.tsx | 173 +++++------------- 10 files changed, 172 insertions(+), 419 deletions(-) delete mode 100644 surfsense_backend/app/agents/new_chat/tools/write_todos.py diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 8fd5f3b71..6c8deb409 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -50,6 +50,9 @@ def create_surfsense_deep_agent( - display_image: Display images in chat - scrape_webpage: Extract content from webpages + The agent also includes TodoListMiddleware by default (via create_deep_agent) which provides: + - write_todos: Create and update planning/todo lists for complex tasks + The system prompt can be configured via agent_config: - Custom system instructions (or use defaults) - Citation toggle (enable/disable citation requirements) @@ -138,6 +141,7 @@ def create_surfsense_deep_agent( system_prompt = build_surfsense_system_prompt() # Create the deep agent with system prompt and checkpointer + # Note: TodoListMiddleware (write_todos) is included by default in create_deep_agent agent = create_deep_agent( model=llm, tools=tools, diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 54fa77c2e..ae4f96fdd 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -111,64 +111,19 @@ You have access to the following tools: * Don't show every image - just the most relevant 1-3 images that enhance understanding. 6. write_todos: Create and update a planning/todo list to break down complex tasks. - - Use this tool when you need to plan your approach to a complex task. - - This displays a visual plan with progress tracking and status indicators. - - - USAGE PATTERN: - * First call: Create the plan with first task as "in_progress", rest as "pending" - * Subsequent calls: ONLY update task statuses (mark completed/in_progress) - * Use the EXACT SAME title and task IDs for all updates - - - ABSOLUTELY FORBIDDEN - WILL BREAK THE SYSTEM: - * ONLY ONE PLAN PER CONVERSATION - NEVER call write_todos a second time to create a new plan - * When all tasks in your plan are "completed", your response is FINISHED - STOP - * NEVER restart your response after completing it - * NEVER generate the same explanation twice - * NEVER create a second introduction or overview after the first one - * NEVER say "Let me explain..." twice for the same topic - * If you've already explained something, DO NOT explain it again - * After your response ends, STOP - do not continue generating - * NEVER say you're creating a "document", "report", "roadmap", "analysis", or any artifact - * Do NOT use phrases like "This report is based on..." or "Based on my research..." - * Just answer the question directly - do not roleplay producing a deliverable - - - CORRECT BEHAVIOR: - * Call write_todos to update statuses as you progress - * Each section of your response appears EXACTLY ONCE - * When you finish explaining all tasks, your response is COMPLETE - * Do NOT generate additional content after concluding - - - CONTENT QUALITY: - * Provide thorough, detailed explanations for each task - * The restriction is on DUPLICATING content, not on depth or detail - * Each task deserves a complete, comprehensive explanation - * Be as detailed as needed - just don't repeat yourself - + - IMPORTANT: Use this tool when the user asks you to create a plan, break down a task, or explain something in structured steps. + - This tool creates a visual plan with progress tracking that the user can see in the UI. - When to use: - * Breaking down a complex multi-step task (3-5 tasks recommended) - * Showing the user what steps you'll take to solve their problem - * Creating an implementation roadmap - + * User asks to "create a plan" or "break down" a task + * User asks for "steps" to do something + * User asks you to "explain" something in sections + * Any multi-step task that would benefit from structured planning - Args: - todos: List of todo items, each with: - * id: Unique identifier (KEEP SAME IDs across updates) - * content: Description of the task (KEEP SAME content across updates) - * status: "pending", "in_progress", or "completed" - * description: Optional subtask/detail text shown when the item is expanded (e.g., "Analyzing document structure and key concepts") - - title: Title for the plan (MUST BE IDENTICAL across all updates) - - description: Optional context description - - - Returns: A visual plan card with progress bar and status indicators - - - CORRECT PATTERN: - 1. Create plan with task 1 as "in_progress" - 2. Explain task 1 content in detail - 3. Update plan: task 1 "completed", task 2 "in_progress" - 4. Explain task 2 content (NEW content, not repeating task 1) - 5. Continue until all tasks are "completed" - 6. When all tasks are "completed", your response is FINISHED - 7. STOP IMMEDIATELY - do NOT create another plan or continue generating - 8. ONE PLAN ONLY - never call write_todos again after completing all tasks + * content: Description of the task (required) + * status: "pending", "in_progress", or "completed" (required) + - The tool automatically adds IDs and formats the output for the UI. + - Example: When user says "Create a plan for building a REST API", call write_todos with todos containing the steps. - User: "Fetch all my notes and what's in them?" @@ -227,21 +182,13 @@ You have access to the following tools: - Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")` - Then provide your explanation, referencing the displayed image -- User: "Help me implement a user authentication system" - - Step 1: Create plan with task 1 in_progress: - `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "in_progress"}, {"id": "2", "content": "Set up password hashing", "status": "pending"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` - - Step 2: Provide DETAILED explanation of database schema design - - Step 3: Update plan (task 1 done, task 2 in_progress): - `write_todos(title="Auth Plan", todos=[{"id": "1", "content": "Design database schema", "status": "completed"}, {"id": "2", "content": "Set up password hashing", "status": "in_progress"}, {"id": "3", "content": "Create endpoints", "status": "pending"}])` - - Step 4: Provide DETAILED explanation of password hashing (NEW content only) - - Step 5: Update plan, explain endpoints in detail - - Step 6: Mark all complete, END response - DO NOT restart or regenerate - - FORBIDDEN: Do not go back and explain schema again after step 2 +- User: "Create a plan for building a user authentication system" + - Call: `write_todos(todos=[{"content": "Design database schema for users and sessions", "status": "in_progress"}, {"content": "Implement registration and login endpoints", "status": "pending"}, {"content": "Add password reset functionality", "status": "pending"}])` + - Then explain each step in detail as you work through them -- User: "How should I approach refactoring this large codebase?" - - Create plan, explain each step with thorough detail, update statuses as you go - - Each explanation is comprehensive but appears ONLY ONCE - - When finished with all tasks, STOP - do not continue generating +- User: "Break down how to build a REST API into steps" + - Call: `write_todos(todos=[{"content": "Design API endpoints and data models", "status": "in_progress"}, {"content": "Set up server framework and routing", "status": "pending"}, {"content": "Implement CRUD operations", "status": "pending"}, {"content": "Add authentication and error handling", "status": "pending"}])` + - Then provide detailed explanations for each step """ diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 5c746d726..bc305aecc 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -48,7 +48,6 @@ from .knowledge_base import create_search_knowledge_base_tool from .link_preview import create_link_preview_tool from .podcast import create_generate_podcast_tool from .scrape_webpage import create_scrape_webpage_tool -from .write_todos import create_write_todos_tool # ============================================================================= # Tool Definition @@ -126,13 +125,7 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ ), requires=[], # firecrawl_api_key is optional ), - # Planning/Todo tool - creates visual todo lists - ToolDefinition( - name="write_todos", - description="Create a planning/todo list to break down complex tasks", - factory=lambda deps: create_write_todos_tool(), - requires=[], - ), + # Note: write_todos is now provided by TodoListMiddleware from deepagents # ========================================================================= # ADD YOUR CUSTOM TOOLS BELOW # ========================================================================= diff --git a/surfsense_backend/app/agents/new_chat/tools/write_todos.py b/surfsense_backend/app/agents/new_chat/tools/write_todos.py deleted file mode 100644 index 466c74997..000000000 --- a/surfsense_backend/app/agents/new_chat/tools/write_todos.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Write todos tool for the SurfSense agent. - -This module provides a tool for creating and displaying a planning/todo list -in the chat UI. It helps the agent break down complex tasks into steps. -""" - -from typing import Any - -from langchain_core.tools import tool - - -def create_write_todos_tool(): - """ - Factory function to create the write_todos tool. - - Returns: - A configured tool function for writing todos/plans. - """ - - @tool - async def write_todos( - todos: list[dict[str, Any]], - title: str = "Planning Approach", - description: str | None = None, - ) -> dict[str, Any]: - """ - Create a planning/todo list to break down a complex task. - - Use this tool when you need to plan your approach to a complex task - or show the user a step-by-step breakdown of what you'll do. - - This displays a visual plan with: - - Progress tracking (X of Y complete) - - Status indicators (pending, in progress, completed, cancelled) - - Expandable details for each step - - Args: - todos: List of todo items. Each item should have: - - id: Unique identifier for the todo - - content: Description of the task - - status: One of "pending", "in_progress", "completed", "cancelled" - - description: Optional subtask/detail text shown when the item is expanded - title: Title for the plan (default: "Planning Approach") - description: Optional description providing context - - Returns: - A dictionary containing the plan data for the UI to render. - - Example: - write_todos( - title="Implementation Plan", - description="Steps to add the new feature", - todos=[ - {"id": "1", "content": "Analyze requirements", "status": "completed", "description": "Reviewed all user stories and acceptance criteria"}, - {"id": "2", "content": "Design solution", "status": "in_progress", "description": "Creating component architecture and data flow diagrams"}, - {"id": "3", "content": "Write code", "status": "pending"}, - {"id": "4", "content": "Add tests", "status": "pending", "description": "Unit tests and integration tests for all new components"}, - ] - ) - """ - # Generate a unique plan ID - import uuid - - plan_id = f"plan-{uuid.uuid4().hex[:8]}" - - # Transform todos to the expected format for the UI - formatted_todos = [] - for i, todo in enumerate(todos): - todo_id = todo.get("id", f"todo-{i}") - content = todo.get("content", "") - status = todo.get("status", "pending") - todo_description = todo.get("description") - - # Validate status - valid_statuses = ["pending", "in_progress", "completed", "cancelled"] - if status not in valid_statuses: - status = "pending" - - todo_item = { - "id": todo_id, - "label": content, - "status": status, - } - - # Only include description if provided - if todo_description: - todo_item["description"] = todo_description - - formatted_todos.append(todo_item) - - return { - "id": plan_id, - "title": title, - "description": description, - "todos": formatted_todos, - } - - return write_todos diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 5bb33e399..602dac0a5 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -69,6 +69,30 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str: return "\n".join(context_parts) +def extract_todos_from_deepagents(command_output) -> dict: + """ + Extract todos from deepagents' TodoListMiddleware Command output. + + deepagents returns a Command object with: + - Command.update['todos'] = [{'content': '...', 'status': '...'}] + + Returns the todos directly (no transformation needed - UI matches deepagents format). + """ + todos_data = [] + if hasattr(command_output, "update"): + # It's a Command object from deepagents + update = command_output.update + todos_data = update.get("todos", []) + elif isinstance(command_output, dict): + # Already a dict - check if it has todos directly or in update + if "todos" in command_output: + todos_data = command_output.get("todos", []) + elif "update" in command_output and isinstance(command_output["update"], dict): + todos_data = command_output["update"].get("todos", []) + + return {"todos": todos_data} + + async def stream_new_chat( user_query: str, search_space_id: int, @@ -557,9 +581,11 @@ async def stream_new_chat( tool_name = event.get("name", "unknown_tool") raw_output = event.get("data", {}).get("output", "") - # Extract content from ToolMessage if needed - # LangGraph may return a ToolMessage object instead of raw dict - if hasattr(raw_output, "content"): + # Handle deepagents' write_todos Command object specially + if tool_name == "write_todos" and hasattr(raw_output, "update"): + # deepagents returns a Command object - extract todos directly + tool_output = extract_todos_from_deepagents(raw_output) + elif hasattr(raw_output, "content"): # It's a ToolMessage object - extract the content content = raw_output.content # If content is a string that looks like JSON, try to parse it @@ -721,12 +747,10 @@ async def stream_new_chat( elif tool_name == "write_todos": # Build completion items for planning if isinstance(tool_output, dict): - plan_title = tool_output.get("title", "Plan") todos = tool_output.get("todos", []) todo_count = len(todos) if isinstance(todos, list) else 0 completed_items = [ *last_active_step_items, - f"Plan: {plan_title[:50]}{'...' if len(plan_title) > 50 else ''}", f"Tasks: {todo_count} steps defined", ] else: @@ -883,11 +907,10 @@ async def stream_new_chat( ) # Send terminal message with plan info if isinstance(tool_output, dict): - title = tool_output.get("title", "Plan") todos = tool_output.get("todos", []) todo_count = len(todos) if isinstance(todos, list) else 0 yield streaming_service.format_terminal_info( - f"Plan created: {title} ({todo_count} tasks)", + f"Plan created ({todo_count} tasks)", "success", ) else: diff --git a/surfsense_web/atoms/chat/plan-state.atom.ts b/surfsense_web/atoms/chat/plan-state.atom.ts index 22c33ff90..2436dd300 100644 --- a/surfsense_web/atoms/chat/plan-state.atom.ts +++ b/surfsense_web/atoms/chat/plan-state.atom.ts @@ -11,15 +11,13 @@ import { atom } from "jotai"; export interface PlanTodo { id: string; - label: string; + content: string; status: "pending" | "in_progress" | "completed" | "cancelled"; - description?: string; } export interface PlanState { id: string; title: string; - description?: string; todos: PlanTodo[]; lastUpdated: number; /** The toolCallId of the first component that rendered this plan */ @@ -96,7 +94,6 @@ export const planStatesAtom = atom>(new Map()); export interface UpdatePlanInput { id: string; title: string; - description?: string; todos: PlanTodo[]; toolCallId: string; } @@ -119,7 +116,6 @@ export const updatePlanStateAtom = atom(null, (get, set, plan: UpdatePlanInput) states.set(canonicalTitle, { id: plan.id, title: canonicalTitle, - description: plan.description, todos: plan.todos, lastUpdated: Date.now(), ownerToolCallId, @@ -152,12 +148,10 @@ export interface HydratePlanInput { result: { id?: string; title?: string; - description?: string; todos?: Array<{ - id: string; - label: string; + id?: string; + content: string; status: "pending" | "in_progress" | "completed" | "cancelled"; - description?: string; }>; }; } @@ -166,7 +160,7 @@ export const hydratePlanStateAtom = atom(null, (get, set, plan: HydratePlanInput if (!plan.result?.todos || plan.result.todos.length === 0) return; const states = new Map(get(planStatesAtom)); - const title = plan.result.title || "Planning Approach"; + const title = plan.result.title || "Plan"; // Register this as the owner if no plan exists yet registerPlanOwner(title, plan.toolCallId); @@ -181,8 +175,11 @@ export const hydratePlanStateAtom = atom(null, (get, set, plan: HydratePlanInput states.set(canonicalTitle, { id: plan.result.id || `plan-${Date.now()}`, title: canonicalTitle, - description: plan.result.description, - todos: plan.result.todos, + todos: plan.result.todos.map((t, i) => ({ + id: t.id || `todo-${i}`, + content: t.content, + status: t.status, + })), lastUpdated: Date.now(), ownerToolCallId, }); diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 65ddf818c..e48919de2 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -77,10 +77,4 @@ export { type PlanTodo, type TodoStatus, } from "./plan"; -export { - WriteTodosToolUI, - WriteTodosArgsSchema, - WriteTodosResultSchema, - type WriteTodosArgs, - type WriteTodosResult, -} from "./write-todos"; +export { WriteTodosToolUI, WriteTodosSchema, type WriteTodosData } from "./write-todos"; diff --git a/surfsense_web/components/tool-ui/plan/plan.tsx b/surfsense_web/components/tool-ui/plan/plan.tsx index d99dbc1c6..5c9335022 100644 --- a/surfsense_web/components/tool-ui/plan/plan.tsx +++ b/surfsense_web/components/tool-ui/plan/plan.tsx @@ -1,22 +1,16 @@ "use client"; -import { CheckCircle2, Circle, CircleDashed, PartyPopper, XCircle } from "lucide-react"; +import { CheckCircle2, Circle, CircleDashed, ListTodo, PartyPopper, XCircle } from "lucide-react"; import type { FC } from "react"; import { useMemo, useState } from "react"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; import type { Action, ActionsConfig } from "../shared/schema"; -import type { PlanTodo, TodoStatus } from "./schema"; +import type { TodoStatus } from "./schema"; // ============================================================================ // Status Icon Component @@ -57,7 +51,7 @@ const StatusIcon: FC = ({ status, className, isStreaming = true // ============================================================================ interface TodoItemProps { - todo: PlanTodo; + todo: { id: string; content: string; status: TodoStatus }; /** When false, in_progress items show as static (no spinner/pulse) */ isStreaming?: boolean; } @@ -67,38 +61,22 @@ const TodoItem: FC = ({ todo, isStreaming = true }) => { // Only show shimmer animation if streaming and in progress const isShimmer = todo.status === "in_progress" && isStreaming; - // Render the label with optional shimmer effect - const renderLabel = () => { + // Render the content with optional shimmer effect + const renderContent = () => { if (isShimmer) { - return ; + return ; } return ( - - {todo.label} + + {todo.content} ); }; - if (todo.description) { - return ( - - -
- - {renderLabel()} -
-
- -

{todo.description}

-
-
- ); - } - return (
- {renderLabel()} + {renderContent()}
); }; @@ -110,8 +88,7 @@ const TodoItem: FC = ({ todo, isStreaming = true }) => { export interface PlanProps { id: string; title: string; - description?: string; - todos: PlanTodo[]; + todos: Array<{ id: string; content: string; status: TodoStatus }>; maxVisibleTodos?: number; showProgress?: boolean; /** When false, in_progress items show as static (no spinner/pulse animations) */ @@ -125,7 +102,6 @@ export interface PlanProps { export const Plan: FC = ({ id, title, - description, todos, maxVisibleTodos = 4, showProgress = true, @@ -151,9 +127,6 @@ export const Plan: FC = ({ const hiddenTodos = todos.slice(maxVisibleTodos); const hasHiddenTodos = hiddenTodos.length > 0; - // Check if any todo has a description (for accordion mode) - const hasDescriptions = todos.some((t) => t.description); - // Handle action click const handleAction = (actionId: string) => { if (onBeforeResponseAction && !onBeforeResponseAction(actionId)) { @@ -172,22 +145,7 @@ export const Plan: FC = ({ ].filter(Boolean) as Action[]; }, [responseActions]); - // Get default expanded items (in_progress items with descriptions) - const defaultExpandedIds = useMemo(() => { - return todos.filter((t) => t.description && t.status === "in_progress").map((t) => t.id); - }, [todos]); - - const TodoList: FC<{ items: PlanTodo[] }> = ({ items }) => { - if (hasDescriptions) { - return ( - - {items.map((todo) => ( - - ))} - - ); - } - + const TodoList: FC<{ items: typeof todos }> = ({ items }) => { return (
{items.map((todo) => ( @@ -201,11 +159,9 @@ export const Plan: FC = ({
-
- {title} - {description && ( - {description} - )} +
+ + {title}
{isAllComplete && (
@@ -216,13 +172,13 @@ export const Plan: FC = ({ {showProgress && (
-
- - {progress.completed} of {progress.total} complete - - {Math.round(progress.percentage)}% -
- +
+ + {progress.completed} of {progress.total} complete + + {Math.round(progress.percentage)}% +
+
)} diff --git a/surfsense_web/components/tool-ui/plan/schema.ts b/surfsense_web/components/tool-ui/plan/schema.ts index fed49128a..a8263cf71 100644 --- a/surfsense_web/components/tool-ui/plan/schema.ts +++ b/surfsense_web/components/tool-ui/plan/schema.ts @@ -8,23 +8,25 @@ export type TodoStatus = z.infer; /** * Single todo item in a plan + * Matches deepagents TodoListMiddleware output: { content, status } + * id is auto-generated if not provided */ export const PlanTodoSchema = z.object({ - id: z.string(), - label: z.string(), + id: z.string().optional(), + content: z.string(), status: TodoStatusSchema, - description: z.string().optional(), }); export type PlanTodo = z.infer; /** * Serializable plan schema for tool results + * Matches deepagents TodoListMiddleware output format + * id/title are auto-generated if not provided */ export const SerializablePlanSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string().optional(), + id: z.string().optional(), + title: z.string().optional(), todos: z.array(PlanTodoSchema).min(1), maxVisibleTodos: z.number().optional(), showProgress: z.boolean().optional(), @@ -33,9 +35,21 @@ export const SerializablePlanSchema = z.object({ export type SerializablePlan = z.infer; /** - * Parse and validate a serializable plan from tool result + * Normalized plan with required fields (after auto-generation) */ -export function parseSerializablePlan(data: unknown): SerializablePlan { +export interface NormalizedPlan { + id: string; + title: string; + todos: Array<{ id: string; content: string; status: TodoStatus }>; + maxVisibleTodos?: number; + showProgress?: boolean; +} + +/** + * Parse and normalize a plan from tool result + * Auto-generates id/title if not provided (for deepagents compatibility) + */ +export function parseSerializablePlan(data: unknown): NormalizedPlan { const result = SerializablePlanSchema.safeParse(data); if (!result.success) { @@ -45,22 +59,33 @@ export function parseSerializablePlan(data: unknown): SerializablePlan { const obj = (data && typeof data === "object" ? data : {}) as Record; return { - id: typeof obj.id === "string" ? obj.id : "unknown", + id: typeof obj.id === "string" ? obj.id : `plan-${Date.now()}`, title: typeof obj.title === "string" ? obj.title : "Plan", - description: typeof obj.description === "string" ? obj.description : undefined, todos: Array.isArray(obj.todos) - ? obj.todos.map((t, i) => ({ - id: typeof (t as any)?.id === "string" ? (t as any).id : `todo-${i}`, - label: typeof (t as any)?.label === "string" ? (t as any).label : "Task", - status: TodoStatusSchema.safeParse((t as any)?.status).success - ? (t as any).status - : "pending", - description: - typeof (t as any)?.description === "string" ? (t as any).description : undefined, - })) - : [{ id: "1", label: "No tasks", status: "pending" as const }], + ? obj.todos.map((t: unknown, i: number) => { + const todo = t as Record; + return { + id: typeof todo?.id === "string" ? todo.id : `todo-${i}`, + content: typeof todo?.content === "string" ? todo.content : "Task", + status: TodoStatusSchema.safeParse(todo?.status).success + ? (todo.status as TodoStatus) + : ("pending" as const), + }; + }) + : [{ id: "1", content: "No tasks", status: "pending" as const }], }; } - return result.data; + // Normalize: add id/title if missing + return { + id: result.data.id || `plan-${Date.now()}`, + title: result.data.title || "Plan", + todos: result.data.todos.map((t, i) => ({ + id: t.id || `todo-${i}`, + content: t.content, + status: t.status, + })), + maxVisibleTodos: result.data.maxVisibleTodos, + showProgress: result.data.showProgress, + }; } diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx index 81e7ab978..c3db8879a 100644 --- a/surfsense_web/components/tool-ui/write-todos.tsx +++ b/surfsense_web/components/tool-ui/write-todos.tsx @@ -14,73 +14,30 @@ import { import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan"; // ============================================================================ -// Zod Schemas +// Zod Schemas - Matching deepagents TodoListMiddleware output // ============================================================================ /** - * Schema for a single todo item in the args - * Note: Using nullish() with transform to convert null → undefined for Plan compatibility + * Schema for a single todo item (matches deepagents output) */ -const WriteTodosArgsTodoSchema = z.object({ - id: z.string(), +const TodoItemSchema = z.object({ content: z.string(), status: TodoStatusSchema, - description: z - .string() - .nullish() - .transform((v) => v ?? undefined), }); /** - * Schema for write_todos tool arguments - * Note: Using nullish() with transform to convert null → undefined for Plan compatibility + * Schema for write_todos tool args/result (matches deepagents output) + * deepagents provides: { todos: [{ content, status }] } */ -const WriteTodosArgsSchema = z.object({ - title: z - .string() - .nullish() - .transform((v) => v ?? undefined), - description: z - .string() - .nullish() - .transform((v) => v ?? undefined), - todos: z.array(WriteTodosArgsTodoSchema).nullish(), -}); - -/** - * Schema for a single todo item in the result - * Note: Using nullish() with transform to convert null → undefined for Plan compatibility - */ -const WriteTodosResultTodoSchema = z.object({ - id: z.string(), - label: z.string(), - status: TodoStatusSchema, - description: z - .string() - .nullish() - .transform((v) => v ?? undefined), -}); - -/** - * Schema for write_todos tool result - * Note: Using nullish() with transform to convert null → undefined for Plan compatibility - */ -const WriteTodosResultSchema = z.object({ - id: z.string(), - title: z.string(), - description: z - .string() - .nullish() - .transform((v) => v ?? undefined), - todos: z.array(WriteTodosResultTodoSchema), +const WriteTodosSchema = z.object({ + todos: z.array(TodoItemSchema).nullish(), }); // ============================================================================ // Types // ============================================================================ -type WriteTodosArgs = z.infer; -type WriteTodosResult = z.infer; +type WriteTodosData = z.infer; /** * Loading state component @@ -96,103 +53,65 @@ function WriteTodosLoading() { ); } -/** - * Transform tool args to result format - * This handles the case where the LLM is streaming the tool call - */ -function transformArgsToResult(args: WriteTodosArgs): WriteTodosResult | null { - if (!args.todos || !Array.isArray(args.todos) || args.todos.length === 0) { - return null; - } - - return { - id: `plan-${Date.now()}`, - title: args.title || "Planning Approach", - description: args.description, - todos: args.todos.map((todo, index) => ({ - id: todo.id || `todo-${index}`, - label: todo.content || "Task", - status: todo.status || "pending", - description: todo.description, - })), - }; -} - /** * WriteTodos Tool UI Component * * Displays the agent's planning/todo list with a beautiful UI. - * Shows progress, status indicators, and expandable details. + * Uses deepagents TodoListMiddleware output directly: { todos: [{ content, status }] } * - * FIXED POSITION: When the same plan (by title) is updated multiple times, + * FIXED POSITION: When multiple write_todos calls happen in a conversation, * only the FIRST component renders. Subsequent updates just update the - * shared state, and the first component reads from it. This prevents - * layout shift when plans are updated. + * shared state, and the first component reads from it. */ -export const WriteTodosToolUI = makeAssistantToolUI({ +export const WriteTodosToolUI = makeAssistantToolUI({ toolName: "write_todos", render: function WriteTodosUI({ args, result, status, toolCallId }) { const updatePlanState = useSetAtom(updatePlanStateAtom); const planStates = useAtomValue(planStatesAtom); - // Check if the THREAD is running (not just this tool) - // This hook subscribes to state changes, so it re-renders when thread stops + // Check if the THREAD is running const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); - // Get the plan data (from result or args) - const planData = result || transformArgsToResult(args); - const rawTitle = planData?.title || args.title || "Planning Approach"; + // Use result if available, otherwise args (for streaming) + const data = result || args; + const hasTodos = data?.todos && data.todos.length > 0; - // SYNCHRONOUS ownership check - happens immediately, no race conditions - // ONE PLAN PER CONVERSATION: Only first write_todos call becomes owner + // Fixed title for all plans in conversation + const planTitle = "Plan"; + + // SYNCHRONOUS ownership check const isOwner = useMemo(() => { - return registerPlanOwner(rawTitle, toolCallId); - }, [rawTitle, toolCallId]); + return registerPlanOwner(planTitle, toolCallId); + }, [planTitle, toolCallId]); - // Get canonical title - always use the FIRST plan's title - // This ensures all updates go to the same plan state - const planTitle = useMemo(() => getCanonicalPlanTitle(rawTitle), [rawTitle]); + // Get canonical title + const canonicalTitle = useMemo(() => getCanonicalPlanTitle(planTitle), [planTitle]); - // Register/update the plan state - ALWAYS use canonical title + // Register/update the plan state useEffect(() => { - if (planData) { + if (hasTodos) { + const normalizedPlan = parseSerializablePlan({ todos: data.todos }); updatePlanState({ - id: planData.id, - title: planTitle, // Use canonical title, not raw title - description: planData.description, - todos: planData.todos, + id: normalizedPlan.id, + title: canonicalTitle, + todos: normalizedPlan.todos, toolCallId, }); } - }, [planData, planTitle, updatePlanState, toolCallId]); + }, [data, hasTodos, canonicalTitle, updatePlanState, toolCallId]); - // Update when result changes (for streaming updates) - useEffect(() => { - if (result) { - updatePlanState({ - id: result.id, - title: planTitle, // Use canonical title, not raw title - description: result.description, - todos: result.todos, - toolCallId, - }); - } - }, [result, planTitle, updatePlanState, toolCallId]); + // Get the current plan state + const currentPlanState = planStates.get(canonicalTitle); - // Get the current plan state (may be updated by other components) - const currentPlanState = planStates.get(planTitle); - - // If we're NOT the owner, render nothing (the owner will render) + // If we're NOT the owner, render nothing if (!isOwner) { return null; } - // Loading state - tool is still running (no data yet) + // Loading state if (status.type === "running" || status.type === "requires-action") { - // Try to show partial results from args while streaming - const partialResult = transformArgsToResult(args); - if (partialResult) { - const plan = parseSerializablePlan(partialResult); + if (hasTodos) { + const plan = parseSerializablePlan({ todos: data.todos }); return (
@@ -206,11 +125,8 @@ export const WriteTodosToolUI = makeAssistantToolUI @@ -222,23 +138,20 @@ export const WriteTodosToolUI = makeAssistantToolUI; } - const plan = parseSerializablePlan(planToRender); return (
- +
); }, }); -export { WriteTodosArgsSchema, WriteTodosResultSchema, type WriteTodosArgs, type WriteTodosResult }; +export { WriteTodosSchema, type WriteTodosData }; From 70383931bb113e8b7b504a947d494bc2aa142e2e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:27:29 +0530 Subject: [PATCH 13/24] feat: Apply minor UI styling adjustments to sidebar and progress components. Fixed linting for both frontend and backend --- .../app/tasks/chat/stream_new_chat.py | 6 +++--- .../components/sidebar/all-notes-sidebar.tsx | 4 ++-- surfsense_web/components/tool-ui/plan/plan.tsx | 17 ++++++++++------- .../components/tool-ui/write-todos.tsx | 3 ++- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 602dac0a5..52c27f554 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -72,10 +72,10 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str: def extract_todos_from_deepagents(command_output) -> dict: """ Extract todos from deepagents' TodoListMiddleware Command output. - + deepagents returns a Command object with: - Command.update['todos'] = [{'content': '...', 'status': '...'}] - + Returns the todos directly (no transformation needed - UI matches deepagents format). """ todos_data = [] @@ -89,7 +89,7 @@ def extract_todos_from_deepagents(command_output) -> dict: todos_data = command_output.get("todos", []) elif "update" in command_output and isinstance(command_output["update"], dict): todos_data = command_output["update"].get("todos", []) - + return {"todos": todos_data} diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx index d66a01780..53bfb6498 100644 --- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -208,7 +208,7 @@ export function AllNotesSidebar({ aria-label={t("all_notes") || "All Notes"} > {/* Header */} -
+

{t("all_notes") || "All Notes"}

+ + Date: Sat, 27 Dec 2025 19:03:46 +0530 Subject: [PATCH 16/24] feat: updated routes --- surfsense_web/app/dashboard/[search_space_id]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/page.tsx index cd697db21..52aadf0f4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/page.tsx @@ -8,8 +8,8 @@ export default function SearchSpaceDashboardPage() { const { search_space_id } = useParams(); useEffect(() => { - router.push(`/dashboard/${search_space_id}/chats`); - }, []); + router.push(`/dashboard/${search_space_id}/new-chat`); + }, [router, search_space_id]); return <>; } From a956b5ff877de91cd613d1e44dc9534aced0188b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:09:42 +0530 Subject: [PATCH 17/24] feat: Introduce document processing indicator UI, update localization. --- .../components/ProcessingIndicator.tsx | 43 +++++++++++++++++++ .../documents/(manage)/page.tsx | 20 ++++++++- .../components/assistant-ui/thread.tsx | 5 +-- surfsense_web/hooks/use-logs.ts | 17 +++++++- surfsense_web/messages/en.json | 4 +- surfsense_web/messages/zh.json | 4 +- 6 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx new file mode 100644 index 000000000..f0d20644c --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Loader2, RefreshCw } from "lucide-react"; +import { motion, AnimatePresence } from "motion/react"; +import { useTranslations } from "next-intl"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +interface ProcessingIndicatorProps { + activeTasksCount: number; +} + +export function ProcessingIndicator({ activeTasksCount }: ProcessingIndicatorProps) { + const t = useTranslations("documents"); + + if (activeTasksCount === 0) return null; + + return ( + + + +
+
+ +
+
+ + {t("processing_documents")} + + + {t("active_tasks_count", { count: activeTasksCount })} + +
+
+
+
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index d4c1a4578..69458d5bc 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -6,8 +6,9 @@ import { RefreshCw } from "lucide-react"; import { motion } from "motion/react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { useLogsSummary } from "@/hooks/use-logs"; import { Button } from "@/components/ui/button"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; @@ -17,6 +18,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; import { DocumentsFilters } from "./components/DocumentsFilters"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { PaginationControls } from "./components/PaginationControls"; +import { ProcessingIndicator } from "./components/ProcessingIndicator"; import type { ColumnVisibility } from "./components/types"; function useDebounced(value: T, delay = 250) { @@ -132,6 +134,20 @@ export default function DocumentsTable() { toast.success(t("refresh_success") || "Documents refreshed"); }, [debouncedSearch, refetchSearch, refetchDocuments, t]); + // Set up polling for active tasks + const { summary } = useLogsSummary(searchSpaceId, 24, { refetchInterval: 5000 }); + const activeTasksCount = summary?.active_tasks.length || 0; + const prevActiveTasksCount = useRef(activeTasksCount); + + // Auto-refresh when a task finishes + useEffect(() => { + if (prevActiveTasksCount.current > activeTasksCount) { + // A task has finished! + refreshCurrentView(); + } + prevActiveTasksCount.current = activeTasksCount; + }, [activeTasksCount, refreshCurrentView]); + // Create a delete function for single document deletion const deleteDocument = useCallback( async (id: number) => { @@ -210,6 +226,8 @@ export default function DocumentsTable() { + + 0 && (
{step.items.map((item, idx) => ( - + {item} ))} diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts index cfd161de0..51d8402d7 100644 --- a/surfsense_web/hooks/use-logs.ts +++ b/surfsense_web/hooks/use-logs.ts @@ -271,7 +271,11 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) { } // Separate hook for log summary -export function useLogsSummary(searchSpaceId: number, hours: number = 24) { +export function useLogsSummary( + searchSpaceId: number, + hours: number = 24, + options: { refetchInterval?: number } = {} +) { const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -308,6 +312,17 @@ export function useLogsSummary(searchSpaceId: number, hours: number = 24) { fetchSummary(); }, [fetchSummary]); + // Set up polling if refetchInterval is provided + useEffect(() => { + if (!options.refetchInterval || options.refetchInterval <= 0) return; + + const intervalId = setInterval(() => { + fetchSummary(); + }, options.refetchInterval); + + return () => clearInterval(intervalId); + }, [fetchSummary, options.refetchInterval]); + const refreshSummary = useCallback(() => { return fetchSummary(); }, [fetchSummary]); diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index b15b611c6..167a87dbc 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -269,7 +269,9 @@ "filter_placeholder": "Filter by title...", "rows_per_page": "Rows per page", "refresh": "Refresh", - "refresh_success": "Documents refreshed" + "refresh_success": "Documents refreshed", + "processing_documents": "Processing documents...", + "active_tasks_count": "{count} active task(s)" }, "add_connector": { "title": "Connect Your Tools", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index efadf3a4e..3701a220d 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -269,7 +269,9 @@ "filter_placeholder": "按标题筛选...", "rows_per_page": "每页行数", "refresh": "刷新", - "refresh_success": "文档已刷新" + "refresh_success": "文档已刷新", + "processing_documents": "正在处理文档...", + "active_tasks_count": "{count} 个正在进行的工作项" }, "add_connector": { "title": "连接您的工具", From 71b8860d206fa4c3b2b687aa5071b9a287b3824e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:41:44 +0530 Subject: [PATCH 18/24] feat: Added active highlighting to chats and notes --- surfsense_web/components/sidebar/all-chats-sidebar.tsx | 2 ++ surfsense_web/components/sidebar/all-notes-sidebar.tsx | 8 +++++++- surfsense_web/components/sidebar/nav-chats.tsx | 5 ++++- surfsense_web/components/sidebar/nav-notes.tsx | 5 ++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx index a820a90c8..79a811ee1 100644 --- a/surfsense_web/components/sidebar/all-chats-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-chats-sidebar.tsx @@ -310,6 +310,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS const isDeleting = deletingThreadId === thread.id; const isArchiving = archivingThreadId === thread.id; const isBusy = isDeleting || isArchiving; + const isActive = currentChatId === thread.id; return (
diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx index 53bfb6498..4f5dee7b8 100644 --- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; @@ -37,7 +37,11 @@ export function AllNotesSidebar({ }: AllNotesSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const params = useParams(); const queryClient = useQueryClient(); + + // Get the current note ID from URL to highlight the open note + const currentNoteId = params.note_id ? Number(params.note_id) : null; const [deletingNoteId, setDeletingNoteId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [mounted, setMounted] = useState(false); @@ -260,6 +264,7 @@ export function AllNotesSidebar({
{notes.map((note) => { const isDeleting = deletingNoteId === note.id; + const isActive = currentNoteId === note.id; return (
diff --git a/surfsense_web/components/sidebar/nav-chats.tsx b/surfsense_web/components/sidebar/nav-chats.tsx index 1165d6057..92e814557 100644 --- a/surfsense_web/components/sidebar/nav-chats.tsx +++ b/surfsense_web/components/sidebar/nav-chats.tsx @@ -10,7 +10,7 @@ import { RefreshCw, Trash2, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -71,6 +71,7 @@ export function NavChats({ }: NavChatsProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const pathname = usePathname(); const isMobile = useIsMobile(); const [isDeleting, setIsDeleting] = useState(null); const [isOpen, setIsOpen] = useState(defaultOpen); @@ -142,6 +143,7 @@ export function NavChats({ {chats.map((chat) => { const isDeletingChat = isDeleting === chat.id; + const isActive = pathname === chat.url; return ( @@ -151,6 +153,7 @@ export function NavChats({ disabled={isDeletingChat} className={cn( "pr-8", // Make room for the action button + isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium", isDeletingChat && "opacity-50" )} > diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index f634c2b72..13886da05 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -10,7 +10,7 @@ import { Plus, Trash2, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -72,6 +72,7 @@ export function NavNotes({ }: NavNotesProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const pathname = usePathname(); const isMobile = useIsMobile(); const [isDeleting, setIsDeleting] = useState(null); const [isOpen, setIsOpen] = useState(defaultOpen); @@ -157,6 +158,7 @@ export function NavNotes({ {notes.length > 0 ? ( notes.map((note) => { const isDeletingNote = isDeleting === note.id; + const isActive = pathname === note.url; return ( @@ -166,6 +168,7 @@ export function NavNotes({ disabled={isDeletingNote} className={cn( "pr-8", // Make room for the action button + isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium", isDeletingNote && "opacity-50" )} > From 2f2e1f98001124af9ad5b8e585eb32a9eb67ff44 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:48:45 +0530 Subject: [PATCH 19/24] feat: Add optional polling support to `useLogsSummary` hook via `refetchInterval` option. --- surfsense_web/hooks/use-logs.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts index 3a02ac055..b03d302bb 100644 --- a/surfsense_web/hooks/use-logs.ts +++ b/surfsense_web/hooks/use-logs.ts @@ -117,8 +117,12 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) { }; } -// Separate hook for log summary -export function useLogsSummary(searchSpaceId: number, hours: number = 24) { +// Separate hook for log summary with optional polling support for document processing indicator UI +export function useLogsSummary( + searchSpaceId: number, + hours: number = 24, + options: { refetchInterval?: number } = {} +) { const { data: summary, isLoading: loading, @@ -133,6 +137,10 @@ export function useLogsSummary(searchSpaceId: number, hours: number = 24) { }), enabled: !!searchSpaceId, staleTime: 3 * 60 * 1000, + // Enable refetch interval for document processing indicator polling + refetchInterval: options.refetchInterval && options.refetchInterval > 0 + ? options.refetchInterval + : undefined, }); return { summary, loading, error, refreshSummary: refetch }; From 2570360079195ab5a9c1a4c6375c68e71ebc7ced Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:37:26 +0530 Subject: [PATCH 20/24] fix: logs now showing in docker builds --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index ad6805174..70d7fb07e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -75,7 +75,6 @@ surfsense_backend/lib64/ # Logs **/*.log -**/logs/ # Temporary files **/tmp/ From 8b10b0cd241e28cd77974f0a4f595327e6fa3d7d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 28 Dec 2025 01:07:42 +0530 Subject: [PATCH 21/24] feat: Display document reindexing status in the sidebar by adding document ID to logs --- surfsense_backend/app/routes/logs_routes.py | 4 +++ .../components/ProcessingIndicator.tsx | 4 +-- .../components/sidebar/nav-notes.tsx | 27 +++++++++++++++++-- surfsense_web/contracts/types/log.types.ts | 1 + surfsense_web/hooks/use-logs.ts | 1 + 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/routes/logs_routes.py b/surfsense_backend/app/routes/logs_routes.py index 98fd9141e..e7e00280e 100644 --- a/surfsense_backend/app/routes/logs_routes.py +++ b/surfsense_backend/app/routes/logs_routes.py @@ -319,6 +319,9 @@ async def get_logs_summary( if log.log_metadata else "Unknown" ) + document_id = ( + log.log_metadata.get("document_id") if log.log_metadata else None + ) summary["active_tasks"].append( { "id": log.id, @@ -326,6 +329,7 @@ async def get_logs_summary( "message": log.message, "started_at": log.created_at, "source": log.source, + "document_id": document_id, } ) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx index f0d20644c..827694d22 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/ProcessingIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import { Loader2, RefreshCw } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { useTranslations } from "next-intl"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -22,7 +22,7 @@ export function ProcessingIndicator({ activeTasksCount }: ProcessingIndicatorPro exit={{ opacity: 0, height: 0, marginBottom: 0 }} transition={{ duration: 0.3 }} > - +
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index 13886da05..e6ad7ea23 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -12,7 +12,8 @@ import { } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useLogsSummary } from "@/hooks/use-logs"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { @@ -78,6 +79,23 @@ export function NavNotes({ const [isOpen, setIsOpen] = useState(defaultOpen); const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + // Poll for active reindexing tasks to show inline loading indicators + const { summary } = useLogsSummary( + searchSpaceId ? Number(searchSpaceId) : 0, + 24, + { refetchInterval: 2000 } + ); + + // Create a Set of document IDs that are currently being reindexed + const reindexingDocumentIds = useMemo(() => { + if (!summary?.active_tasks) return new Set(); + return new Set( + summary.active_tasks + .filter((task) => task.document_id != null) + .map((task) => task.document_id as number) + ); + }, [summary?.active_tasks]); + // Auto-collapse on smaller screens when Sources is expanded useEffect(() => { if (isSourcesExpanded && isMobile) { @@ -159,6 +177,7 @@ export function NavNotes({ notes.map((note) => { const isDeletingNote = isDeleting === note.id; const isActive = pathname === note.url; + const isReindexing = note.id ? reindexingDocumentIds.has(note.id) : false; return ( @@ -172,7 +191,11 @@ export function NavNotes({ isDeletingNote && "opacity-50" )} > - + {isReindexing ? ( + + ) : ( + + )} {note.name} diff --git a/surfsense_web/contracts/types/log.types.ts b/surfsense_web/contracts/types/log.types.ts index b1e95bbf2..ac81d2d0d 100644 --- a/surfsense_web/contracts/types/log.types.ts +++ b/surfsense_web/contracts/types/log.types.ts @@ -85,6 +85,7 @@ export const logActiveTask = z.object({ message: z.string(), started_at: z.string(), source: z.string().nullable().optional(), + document_id: z.number().nullable().optional(), }); export const logFailure = z.object({ id: z.number(), diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts index b03d302bb..643222c0c 100644 --- a/surfsense_web/hooks/use-logs.ts +++ b/surfsense_web/hooks/use-logs.ts @@ -39,6 +39,7 @@ export interface LogSummary { message: string; started_at: string; source?: string; + document_id?: number; }>; recent_failures: Array<{ id: number; From 2c64fcc38e1c7b2b20e9eee54f86361f69b633d5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 28 Dec 2025 01:20:39 +0530 Subject: [PATCH 22/24] feat: Enhance sidebar navigation active state logic, introduce static display items, refine active item styling --- .../components/sidebar/all-chats-sidebar.tsx | 2 +- .../components/sidebar/all-notes-sidebar.tsx | 2 +- .../components/sidebar/nav-chats.tsx | 2 +- surfsense_web/components/sidebar/nav-main.tsx | 37 +++++++++++++++++-- .../components/sidebar/nav-notes.tsx | 2 +- .../components/sidebar/nav-secondary.tsx | 21 ++++++++--- surfsense_web/components/ui/sidebar.tsx | 2 +- 7 files changed, 53 insertions(+), 15 deletions(-) diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx index 79a811ee1..ef55142fa 100644 --- a/surfsense_web/components/sidebar/all-chats-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-chats-sidebar.tsx @@ -319,7 +319,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS "group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm", "hover:bg-accent hover:text-accent-foreground", "transition-colors cursor-pointer", - isActive && "bg-accent text-accent-foreground font-medium", + isActive && "bg-accent text-accent-foreground", isBusy && "opacity-50 pointer-events-none" )} > diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx index 4f5dee7b8..ff9f07175 100644 --- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -273,7 +273,7 @@ export function AllNotesSidebar({ "group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm", "hover:bg-accent hover:text-accent-foreground", "transition-colors cursor-pointer", - isActive && "bg-accent text-accent-foreground font-medium", + isActive && "bg-accent text-accent-foreground", isDeleting && "opacity-50 pointer-events-none" )} > diff --git a/surfsense_web/components/sidebar/nav-chats.tsx b/surfsense_web/components/sidebar/nav-chats.tsx index 92e814557..3bb7167da 100644 --- a/surfsense_web/components/sidebar/nav-chats.tsx +++ b/surfsense_web/components/sidebar/nav-chats.tsx @@ -153,7 +153,7 @@ export function NavChats({ disabled={isDeletingChat} className={cn( "pr-8", // Make room for the action button - isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium", + isActive && "bg-sidebar-accent text-sidebar-accent-foreground", isDeletingChat && "opacity-50" )} > diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx index 606ab2680..ddb700ff3 100644 --- a/surfsense_web/components/sidebar/nav-main.tsx +++ b/surfsense_web/components/sidebar/nav-main.tsx @@ -1,6 +1,7 @@ "use client"; import { ChevronRight, type LucideIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; @@ -35,6 +36,7 @@ interface NavMainProps { export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) { const t = useTranslations("nav_menu"); + const pathname = usePathname(); // Translation function that handles both exact matches and fallback to original const translateTitle = (title: string): string => { @@ -55,6 +57,32 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) { return key ? t(key) : title; }; + // Check if an item is active based on pathname + const isItemActive = useCallback((item: NavItem): boolean => { + if (!pathname) return false; + + // For items without sub-items, check if pathname matches or starts with the URL + if (!item.items?.length) { + // Chat item: active ONLY when on new-chat page without a specific chat ID + // (i.e., exactly /dashboard/{id}/new-chat, not /dashboard/{id}/new-chat/123) + if (item.url.includes("/new-chat")) { + // Match exactly the new-chat base URL (ends with /new-chat) + return pathname.endsWith("/new-chat"); + } + // Logs item: active when on logs page + if (item.url.includes("/logs")) { + return pathname.includes("/logs"); + } + // Check exact match or prefix match + return pathname === item.url || pathname.startsWith(`${item.url}/`); + } + + // For items with sub-items (like Sources), check if any sub-item URL matches + return item.items.some( + (subItem) => pathname === subItem.url || pathname.startsWith(subItem.url) + ); + }, [pathname]); + // Memoize items to prevent unnecessary re-renders const memoizedItems = useMemo(() => items, [items]); @@ -88,14 +116,15 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) { {memoizedItems.map((item, index) => { const translatedTitle = translateTitle(item.title); const hasSub = !!item.items?.length; - const isItemOpen = expandedItems[item.title] ?? item.isActive ?? false; + const isActive = isItemActive(item); + const isItemOpen = expandedItems[item.title] ?? isActive ?? false; return ( handleOpenChange(item.title, open) : undefined} - defaultOpen={!hasSub ? item.isActive : undefined} + defaultOpen={!hasSub ? isActive : undefined} > {hasSub ? ( @@ -105,7 +134,7 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {