diff --git a/surfsense_web/atoms/documents/ui.atoms.ts b/surfsense_web/atoms/documents/ui.atoms.ts index 33740e9c7..a3d481c80 100644 --- a/surfsense_web/atoms/documents/ui.atoms.ts +++ b/surfsense_web/atoms/documents/ui.atoms.ts @@ -5,3 +5,5 @@ export const globalDocumentsQueryParamsAtom = atom; header?: React.ReactNode; @@ -252,14 +231,8 @@ const Composer: FC = () => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); - const [uploadedMentionDocs, setUploadedMentionDocs] = useState< - Record - >({}); - const [isUploadingDocs, setIsUploadingDocs] = useState(false); const editorRef = useRef(null); const editorContainerRef = useRef(null); - const uploadInputRef = useRef(null); - const isFileDialogOpenRef = useRef(false); const documentPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); @@ -401,28 +374,9 @@ const Composer: FC = () => { [showDocumentPopover] ); - const uploadedMentionedDocs = useMemo( - () => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]), - [mentionedDocuments, uploadedMentionDocs] - ); - - const blockingUploadedMentions = useMemo( - () => - uploadedMentionedDocs.filter((doc) => { - const state = uploadedMentionDocs[doc.id]?.state; - return state === "pending" || state === "processing" || state === "failed"; - }), - [uploadedMentionedDocs, uploadedMentionDocs] - ); - // Submit message (blocked during streaming, document picker open, or AI responding to another user) const handleSubmit = useCallback(() => { - if ( - isThreadRunning || - isBlockedByOtherUser || - isUploadingDocs || - blockingUploadedMentions.length > 0 - ) { + if (isThreadRunning || isBlockedByOtherUser) { return; } if (!showDocumentPopover) { @@ -438,8 +392,6 @@ const Composer: FC = () => { showDocumentPopover, isThreadRunning, isBlockedByOtherUser, - isUploadingDocs, - blockingUploadedMentions.length, composerRuntime, setMentionedDocuments, setMentionedDocumentIds, @@ -460,11 +412,6 @@ const Composer: FC = () => { }); return updated; }); - setUploadedMentionDocs((prev) => { - if (!(docId in prev)) return prev; - const { [docId]: _removed, ...rest } = prev; - return rest; - }); }, [setMentionedDocuments, setMentionedDocumentIds] ); @@ -503,168 +450,6 @@ const Composer: FC = () => { [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] ); - const refreshUploadedDocStatuses = useCallback( - async (documentIds: number[]) => { - if (!search_space_id || documentIds.length === 0) return; - const statusResponse = await documentsApiService.getDocumentsStatus({ - queryParams: { - search_space_id: Number(search_space_id), - document_ids: documentIds, - }, - }); - - setUploadedMentionDocs((prev) => { - const next = { ...prev }; - for (const item of statusResponse.items) { - next[item.id] = { - id: item.id, - title: item.title, - document_type: item.document_type, - state: item.status.state, - reason: item.status.reason, - }; - } - return next; - }); - - handleDocumentsMention( - statusResponse.items.map((item) => ({ - id: item.id, - title: item.title, - document_type: item.document_type, - })) - ); - }, - [search_space_id, handleDocumentsMention] - ); - - const handleUploadClick = useCallback(() => { - if (isFileDialogOpenRef.current) return; - isFileDialogOpenRef.current = true; - uploadInputRef.current?.click(); - // Reset after a delay to handle cancellation (which doesn't fire the change event). - setTimeout(() => { - isFileDialogOpenRef.current = false; - }, 1000); - }, []); - - const handleUploadInputChange = useCallback( - async (event: React.ChangeEvent) => { - isFileDialogOpenRef.current = false; - const files = Array.from(event.target.files ?? []); - event.target.value = ""; - if (files.length === 0 || !search_space_id) return; - - if (files.length > CHAT_MAX_FILES) { - toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`); - return; - } - - let totalSize = 0; - for (const file of files) { - if (file.size > CHAT_MAX_FILE_SIZE_BYTES) { - toast.error( - `File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.` - ); - return; - } - totalSize += file.size; - } - if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) { - toast.error( - `Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.` - ); - return; - } - - setIsUploadingDocs(true); - try { - const uploadResponse = await documentsApiService.uploadDocument({ - files, - search_space_id: Number(search_space_id), - }); - const uploadedIds = uploadResponse.document_ids ?? []; - const duplicateIds = uploadResponse.duplicate_document_ids ?? []; - const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds])); - if (idsToMention.length === 0) { - toast.warning("No documents were created or matched from selected files."); - return; - } - - await refreshUploadedDocStatuses(idsToMention); - if (uploadedIds.length > 0 && duplicateIds.length > 0) { - toast.success( - `Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.` - ); - } else if (uploadedIds.length > 0) { - toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`); - } else { - toast.success( - `Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.` - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : "Upload failed"; - toast.error(`Upload failed: ${message}`); - } finally { - setIsUploadingDocs(false); - } - }, - [search_space_id, refreshUploadedDocStatuses] - ); - - // Poll status for uploaded mentioned documents until all are ready or removed. - useEffect(() => { - const trackedIds = uploadedMentionedDocs.map((doc) => doc.id); - const needsPolling = trackedIds.some((id) => { - const state = uploadedMentionDocs[id]?.state; - return state === "pending" || state === "processing"; - }); - if (!needsPolling) return; - - const interval = setInterval(() => { - refreshUploadedDocStatuses(trackedIds).catch((error) => { - console.error("[Composer] Failed to refresh uploaded mention statuses:", error); - }); - }, 2500); - - return () => clearInterval(interval); - }, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]); - - // Push upload status directly onto mention chips (instead of separate status rows). - useEffect(() => { - for (const doc of uploadedMentionedDocs) { - const state = uploadedMentionDocs[doc.id]?.state ?? "pending"; - const statusLabel = - state === "ready" - ? null - : state === "failed" - ? "failed" - : state === "processing" - ? "indexing" - : "queued"; - editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state); - } - }, [uploadedMentionedDocs, uploadedMentionDocs]); - - // Prune upload status entries that are no longer mentioned in the composer. - useEffect(() => { - const activeIds = new Set(mentionedDocuments.map((doc) => doc.id)); - setUploadedMentionDocs((prev) => { - let changed = false; - const next: Record = {}; - for (const [key, value] of Object.entries(prev)) { - const id = Number(key); - if (activeIds.has(id)) { - next[id] = value; - } else { - changed = true; - } - } - return changed ? next : prev; - }); - }, [mentionedDocuments]); - return ( { className="min-h-[24px]" /> - - {/* Document picker popover (portal to body for proper z-index stacking) */} {showDocumentPopover && typeof document !== "undefined" && @@ -724,12 +500,6 @@ const Composer: FC = () => { )} uploadedMentionDocs[doc.id]?.state === "failed" - )} /> @@ -738,29 +508,20 @@ const Composer: FC = () => { interface ComposerActionProps { isBlockedByOtherUser?: boolean; - onUploadClick: () => void; - isUploadingDocs: boolean; - blockingUploadedMentionsCount: number; - hasFailedUploadedMentions: boolean; } const ComposerAction: FC = ({ isBlockedByOtherUser = false, - onUploadClick, - isUploadingDocs, - blockingUploadedMentionsCount, - hasFailedUploadedMentions, }) => { const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); + const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom); - // Check if composer text is empty (chips are represented in mentionedDocuments atom) const isComposerTextEmpty = useAssistantState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; }); const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0; - // Check if a model is configured const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); const { data: preferences } = useAtomValue(llmPreferencesAtom); @@ -770,8 +531,6 @@ const ComposerAction: FC = ({ const agentLlmId = preferences.agent_llm_id; if (agentLlmId === null || agentLlmId === undefined) return false; - // Check if the configured model actually exists - // Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs if (agentLlmId <= 0) { return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; } @@ -781,57 +540,26 @@ const ComposerAction: FC = ({ const isSendDisabled = isComposerEmpty || !hasModelConfigured || - isBlockedByOtherUser || - isUploadingDocs || - blockingUploadedMentionsCount > 0; + isBlockedByOtherUser; return (
- Upload and mention files - - Max 10 files 50 MB each - - Total upload limit: 200 MB -
- ) - } + tooltip="Upload" side="bottom" variant="ghost" size="icon" className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" - aria-label="Upload files" - onClick={onUploadClick} - disabled={isUploadingDocs} + aria-label="Open documents" + onClick={() => setDocumentsSidebarOpen(true)} > - {isUploadingDocs ? ( - - ) : ( - - )} +
- {blockingUploadedMentionsCount > 0 && ( -
- {hasFailedUploadedMentions ? : } - - {hasFailedUploadedMentions - ? "Remove or retry failed uploads" - : "Waiting for uploaded files to finish indexing"} - -
- )} - - {/* Show warning when no model is configured */} - {!hasModelConfigured && blockingUploadedMentionsCount === 0 && ( + {!hasModelConfigured && (
Select a model @@ -844,17 +572,11 @@ const ComposerAction: FC = ({ tooltip={ isBlockedByOtherUser ? "Wait for AI to finish responding" - : hasFailedUploadedMentions - ? "Remove or retry failed uploads before sending" - : blockingUploadedMentionsCount > 0 - ? "Waiting for uploaded files to finish indexing" - : isUploadingDocs - ? "Uploading documents..." - : !hasModelConfigured - ? "Please select a model from the header to start chatting" - : isComposerEmpty - ? "Enter a message to send" - : "Send message" + : !hasModelConfigured + ? "Please select a model from the header to start chatting" + : isComposerEmpty + ? "Enter a message to send" + : "Send message" } side="bottom" type="submit" diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 048f0019f..9eca67880 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { AlertTriangle, Inbox, @@ -17,6 +17,7 @@ import { useTheme } from "next-themes"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; +import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; @@ -114,8 +115,8 @@ export function LayoutDataProvider({ const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); const [isInboxDocked, setIsInboxDocked] = useState(false); - // Documents sidebar state - const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useState(false); + // Documents sidebar state (shared atom so Composer can toggle it) + const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom); // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); @@ -440,7 +441,7 @@ export function LayoutDataProvider({ } router.push(item.url); }, - [router] + [router, setIsDocumentsSidebarOpen] ); const handleNewChat = useCallback(() => { @@ -538,14 +539,14 @@ export function LayoutDataProvider({ setIsAllPrivateChatsSidebarOpen(false); setIsInboxSidebarOpen(false); setIsDocumentsSidebarOpen(false); - }, []); + }, [setIsDocumentsSidebarOpen]); const handleViewAllPrivateChats = useCallback(() => { setIsAllPrivateChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen(false); setIsInboxSidebarOpen(false); setIsDocumentsSidebarOpen(false); - }, []); + }, [setIsDocumentsSidebarOpen]); // Delete handlers const confirmDeleteChat = useCallback(async () => {