diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index daf7a20c7..2038e85dc 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -181,7 +181,9 @@ async def stream_new_chat( context_parts.append(format_attachments_as_context(attachments)) if mentioned_documents: - context_parts.append(format_mentioned_documents_as_context(mentioned_documents)) + context_parts.append( + format_mentioned_documents_as_context(mentioned_documents) + ) if context_parts: context = "\n\n".join(context_parts) 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 dc83d9204..a7fc23802 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 @@ -10,7 +10,12 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, messageDocumentsMapAtom, type MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom"; +import { + type MentionedDocumentInfo, + mentionedDocumentIdsAtom, + mentionedDocumentsAtom, + messageDocumentsMapAtom, +} from "@/atoms/chat/mentioned-documents.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"; @@ -54,15 +59,15 @@ function extractThinkingSteps(content: unknown): ThinkingStep[] { */ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { if (!Array.isArray(content)) return []; - + const docsPart = content.find( - (part: unknown) => - typeof part === "object" && - part !== null && - "type" in part && + (part: unknown) => + typeof part === "object" && + part !== null && + "type" in part && (part as { type: string }).type === "mentioned-documents" ) as { type: "mentioned-documents"; documents: MentionedDocumentInfo[] } | undefined; - + return docsPart?.documents || []; } @@ -179,7 +184,7 @@ export default function NewChatPage() { const restoredThinkingSteps = new Map(); // Extract and restore mentioned documents from persisted messages const restoredDocsMap: Record = {}; - + for (const msg of response.messages) { if (msg.role === "assistant") { const steps = extractThinkingSteps(msg.content); @@ -292,16 +297,20 @@ 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; + 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; appendMessage(threadId, { role: "user", content: persistContent, @@ -626,7 +635,16 @@ export default function NewChatPage() { // Note: We no longer clear thinking steps - they persist with the message } }, - [threadId, searchSpaceId, messages, mentionedDocumentIds, mentionedDocuments, setMentionedDocumentIds, setMentionedDocuments, setMessageDocumentsMap] + [ + threadId, + searchSpaceId, + messages, + mentionedDocumentIds, + mentionedDocuments, + setMentionedDocumentIds, + setMentionedDocuments, + setMessageDocumentsMap, + ] ); // Convert message (pass through since already in correct format) diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 67ce10eee..79ea27d12 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -29,4 +29,3 @@ export interface MentionedDocumentInfo { * This allows displaying which documents were mentioned with each user message. */ export const messageDocumentsMapAtom = atom>({}); - diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d9275cebd..191d60338 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -7,8 +7,8 @@ import { MessagePrimitive, ThreadPrimitive, useAssistantState, - useThreadViewport, useMessage, + useThreadViewport, } from "@assistant-ui/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { @@ -35,9 +35,23 @@ import { } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { type FC, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + type FC, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { createPortal } from "react-dom"; -import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; +import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; +import { + mentionedDocumentIdsAtom, + mentionedDocumentsAtom, + messageDocumentsMapAtom, +} from "@/atoms/chat/mentioned-documents.atom"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { globalNewLLMConfigsAtom, @@ -54,7 +68,10 @@ import { import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { DocumentsDataTable, type DocumentsDataTableRef } from "@/components/new-chat/DocumentsDataTable"; +import { + DocumentsDataTable, + type DocumentsDataTableRef, +} from "@/components/new-chat/DocumentsDataTable"; import { ChainOfThought, ChainOfThoughtContent, @@ -67,7 +84,6 @@ import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; -import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { cn } from "@/lib/utils"; @@ -371,7 +387,7 @@ const getTimeBasedGreeting = (userEmail?: string): string => { const ThreadWelcome: FC = () => { const { data: user } = useAtomValue(currentUserAtom); - + // Memoize greeting so it doesn't change on re-renders (only on user change) const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]); @@ -427,14 +443,14 @@ const Composer: FC = () => { // Check if value contains @ and extract query if (value.includes("@")) { const query = extractMentionQuery(value); - + // Close popup if query starts with space (user typed "@ ") if (query.startsWith(" ")) { setShowDocumentPopover(false); setMentionQuery(""); return; } - + // Reopen popup if @ is present and query doesn't start with space // (handles case where user deleted the space after @) if (!showDocumentPopover) { @@ -504,7 +520,7 @@ const Composer: FC = () => { const input = inputRef.current; const currentValue = input.value; const atIndex = currentValue.lastIndexOf("@"); - + if (atIndex !== -1) { // Remove @ and everything after it const newValue = currentValue.slice(0, atIndex); @@ -520,7 +536,7 @@ const Composer: FC = () => { // Focus the input so user can continue typing input.focus(); } - + // Reset mention query setMentionQuery(""); }; @@ -558,7 +574,11 @@ const Composer: FC = () => { ref={inputRef} onKeyUp={handleKeyUp} onKeyDown={handleKeyDown} - placeholder={mentionedDocuments.length > 0 ? "Ask about these documents..." : "Ask SurfSense (type @ to mention docs)"} + placeholder={ + mentionedDocuments.length > 0 + ? "Ask about these documents..." + : "Ask SurfSense (type @ to mention docs)" + } className="aui-composer-input flex-1 min-w-[120px] max-h-32 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0 py-1" rows={1} autoFocus @@ -567,41 +587,47 @@ const Composer: FC = () => { {/* -------- Document mention popover (rendered via portal) -------- */} - {showDocumentPopover && typeof document !== "undefined" && createPortal( - <> - {/* Backdrop */} -