From 3b33a3efdb5e879f7f5ce8ce63069fb0a89dbd36 Mon Sep 17 00:00:00 2001 From: qorexdev Date: Sat, 28 Mar 2026 04:01:49 +0500 Subject: [PATCH 1/8] refactor: move onboarding animation state into event handlers Remove useEffect that watched stepIndex to drive shouldAnimate/contentKey. stepIndex only changes from handleNext/handlePrev, so set shouldAnimate directly in those handlers. Replace contentKey state (always equal to stepIndex) with stepIndex as the animation key. Fixes #1017 --- surfsense_web/components/onboarding-tour.tsx | 26 +++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 03fad87b6..e097c212d 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -160,6 +160,8 @@ function TourTooltip({ onPrev, onSkip, isDarkMode, + shouldAnimate, + onAnimationEnd, }: { step: TourStep; stepIndex: number; @@ -170,23 +172,12 @@ function TourTooltip({ onPrev: () => void; onSkip: () => void; isDarkMode: boolean; + shouldAnimate: boolean; + onAnimationEnd: () => void; }) { - const [contentKey, setContentKey] = useState(stepIndex); - const [shouldAnimate, setShouldAnimate] = useState(false); - const prevStepIndexRef = useRef(stepIndex); const isLastStep = stepIndex === totalSteps - 1; const isFirstStep = stepIndex === 0; - // Update content key when step changes to trigger animation - // Only animate if stepIndex actually changes (not on initial mount) - useEffect(() => { - if (prevStepIndexRef.current !== stepIndex) { - setShouldAnimate(true); - setContentKey(stepIndex); - prevStepIndexRef.current = stepIndex; - } - }, [stepIndex]); - const bgColor = isDarkMode ? "#27272a" : "#ffffff"; const textColor = isDarkMode ? "#ffffff" : "#18181b"; const mutedTextColor = isDarkMode ? "#a1a1aa" : "#71717a"; @@ -358,11 +349,11 @@ function TourTooltip({ > {/* Content */}
setShouldAnimate(false)} + onAnimationEnd={onAnimationEnd} >

{step.title} @@ -427,6 +418,7 @@ export function OnboardingTour() { const isMobile = useIsMobile(); const [isActive, setIsActive] = useState(false); const [stepIndex, setStepIndex] = useState(0); + const [shouldAnimate, setShouldAnimate] = useState(false); const [targetEl, setTargetEl] = useState(null); const [spotlightTargetEl, setSpotlightTargetEl] = useState(null); const [spotlightStepTarget, setSpotlightStepTarget] = useState(null); @@ -664,6 +656,7 @@ export function OnboardingTour() { const handleNext = useCallback(() => { if (stepIndex < TOUR_STEPS.length - 1) { retryCountRef.current = 0; + setShouldAnimate(true); setStepIndex(stepIndex + 1); } else { // Tour completed - save to localStorage @@ -678,6 +671,7 @@ export function OnboardingTour() { const handlePrev = useCallback(() => { if (stepIndex > 0) { retryCountRef.current = 0; + setShouldAnimate(true); setStepIndex(stepIndex - 1); } }, [stepIndex]); @@ -772,6 +766,8 @@ export function OnboardingTour() { onPrev={handlePrev} onSkip={handleSkip} isDarkMode={isDarkMode} + shouldAnimate={shouldAnimate} + onAnimationEnd={() => setShouldAnimate(false)} /> )} From ee0b59c0fadfc90284dcb1917b47070fe982ee7f Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Fri, 27 Mar 2026 17:58:04 -0700 Subject: [PATCH 2/8] feat: enhance folder and document selection functionality - Updated DocumentNode to use a div instead of a button for better accessibility and added keyboard interaction for selection. - Introduced Checkbox component in FolderNode for selecting folders, with state management for selection (all, some, none). - Implemented folder selection state logic in FolderTreeView to manage document selection across nested folders. - Added handleToggleFolderSelect function in DocumentsSidebar to manage selection of documents based on folder selection. --- .../components/documents/DocumentNode.tsx | 19 +++++++--- .../components/documents/FolderNode.tsx | 17 +++++++++ .../components/documents/FolderTreeView.tsx | 36 ++++++++++++++++++ .../layout/ui/sidebar/DocumentsSidebar.tsx | 38 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index 57a12ab3a..a295696d5 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -89,7 +89,7 @@ export const DocumentNode = React.memo(function DocumentNode({ const isProcessing = statusState === "pending" || statusState === "processing"; const [dropdownOpen, setDropdownOpen] = useState(false); const [exporting, setExporting] = useState(null); - const rowRef = useRef(null); + const rowRef = useRef(null); const handleExport = useCallback( (format: string) => { @@ -102,8 +102,8 @@ export const DocumentNode = React.memo(function DocumentNode({ ); const attachRef = useCallback( - (node: HTMLButtonElement | null) => { - (rowRef as React.MutableRefObject).current = node; + (node: HTMLDivElement | null) => { + (rowRef as React.MutableRefObject).current = node; drag(node); }, [drag] @@ -112,8 +112,9 @@ export const DocumentNode = React.memo(function DocumentNode({ return ( - +

{contextMenuOpen && ( diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index cb314effb..5a76e882d 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -14,6 +14,8 @@ import { import React, { useCallback, useEffect, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { FolderSelectionState } from "./FolderTreeView"; import { ContextMenu, ContextMenuContent, @@ -49,6 +51,8 @@ interface FolderNodeProps { isExpanded: boolean; isRenaming: boolean; childCount: number; + selectionState: FolderSelectionState; + onToggleSelect: (folderId: number, selectAll: boolean) => void; onToggleExpand: (folderId: number) => void; onRename: (folder: FolderDisplay, newName: string) => void; onStartRename: (folderId: number) => void; @@ -88,6 +92,8 @@ export const FolderNode = React.memo(function FolderNode({ isExpanded, isRenaming, childCount, + selectionState, + onToggleSelect, onToggleExpand, onRename, onStartRename, @@ -212,6 +218,10 @@ export const FolderNode = React.memo(function FolderNode({ onStartRename(folder.id); }, [folder, onStartRename]); + const handleCheckChange = useCallback(() => { + onToggleSelect(folder.id, selectionState !== "all"); + }, [folder.id, selectionState, onToggleSelect]); + const FolderIcon = isExpanded ? FolderOpen : Folder; return ( @@ -252,6 +262,13 @@ export const FolderNode = React.memo(function FolderNode({ )} + e.stopPropagation()} + className="h-3.5 w-3.5 shrink-0" + /> + {isRenaming ? ( diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index d7a82fb0b..eec0aa7cc 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -10,6 +10,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode"; import { type FolderDisplay, FolderNode } from "./FolderNode"; +export type FolderSelectionState = "all" | "some" | "none"; + interface FolderTreeViewProps { folders: FolderDisplay[]; documents: DocumentNodeDoc[]; @@ -20,6 +22,7 @@ interface FolderTreeViewProps { doc: { id: number; title: string; document_type: string }, isMentioned: boolean ) => void; + onToggleFolderSelect: (folderId: number, selectAll: boolean) => void; onRenameFolder: (folder: FolderDisplay, newName: string) => void; onDeleteFolder: (folder: FolderDisplay) => void; onMoveFolder: (folder: FolderDisplay) => void; @@ -55,6 +58,7 @@ export function FolderTreeView({ onToggleExpand, mentionedDocIds, onToggleChatMention, + onToggleFolderSelect, onRenameFolder, onDeleteFolder, onMoveFolder, @@ -122,6 +126,36 @@ export function FolderTreeView({ return match; }, [folders, docsByFolder, foldersByParent, activeTypes]); + const folderSelectionStates = useMemo(() => { + const states: Record = {}; + const isSelectable = (d: DocumentNodeDoc) => + d.status?.state !== "pending" && d.status?.state !== "processing"; + + function compute(folderId: number): { selected: number; total: number } { + const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable); + let selected = directDocs.filter((d) => mentionedDocIds.has(d.id)).length; + let total = directDocs.length; + + for (const child of foldersByParent[folderId] ?? []) { + const sub = compute(child.id); + selected += sub.selected; + total += sub.total; + } + + if (total === 0) states[folderId] = "none"; + else if (selected === total) states[folderId] = "all"; + else if (selected > 0) states[folderId] = "some"; + else states[folderId] = "none"; + + return { selected, total }; + } + + for (const f of folders) { + if (states[f.id] === undefined) compute(f.id); + } + return states; + }, [folders, docsByFolder, foldersByParent, mentionedDocIds]); + function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { const key = parentId ?? "root"; const childFolders = (foldersByParent[key] ?? []) @@ -151,6 +185,8 @@ export function FolderTreeView({ isExpanded={expandedIds.has(f.id)} isRenaming={renamingFolderId === f.id} childCount={folderChildCounts[f.id] ?? 0} + selectionState={folderSelectionStates[f.id] ?? "none"} + onToggleSelect={onToggleFolderSelect} onToggleExpand={onToggleExpand} onRename={onRenameFolder} onStartRename={handleStartRename} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 1e1e8f982..f4ddf5ce5 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -348,6 +348,43 @@ export function DocumentsSidebar({ [setSidebarDocs] ); + const handleToggleFolderSelect = useCallback( + (folderId: number, selectAll: boolean) => { + function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] { + const directDocs = (treeDocuments ?? []).filter( + (d) => + d.folderId === parentId && + d.status?.state !== "pending" && + d.status?.state !== "processing", + ); + const childFolders = foldersByParent[String(parentId)] ?? []; + const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id)); + return [...directDocs, ...descendantDocs]; + } + + const subtreeDocs = collectSubtreeDocs(folderId); + if (subtreeDocs.length === 0) return; + + if (selectAll) { + setSidebarDocs((prev) => { + const existingIds = new Set(prev.map((d) => d.id)); + const newDocs = subtreeDocs + .filter((d) => !existingIds.has(d.id)) + .map((d) => ({ + id: d.id, + title: d.title, + document_type: d.document_type as DocumentTypeEnum, + })); + return newDocs.length > 0 ? [...prev, ...newDocs] : prev; + }); + } else { + const idsToRemove = new Set(subtreeDocs.map((d) => d.id)); + setSidebarDocs((prev) => prev.filter((d) => !idsToRemove.has(d.id))); + } + }, + [treeDocuments, foldersByParent, setSidebarDocs], + ); + const isSearchMode = !!debouncedSearch.trim(); const { @@ -625,6 +662,7 @@ export function DocumentsSidebar({ onToggleExpand={toggleFolderExpand} mentionedDocIds={mentionedDocIds} onToggleChatMention={handleToggleChatMention} + onToggleFolderSelect={handleToggleFolderSelect} onRenameFolder={handleRenameFolder} onDeleteFolder={handleDeleteFolder} onMoveFolder={handleMoveFolder} From 80f50043495a4b0af8290cea8191e645bf82250e Mon Sep 17 00:00:00 2001 From: jupal Date: Sat, 28 Mar 2026 04:22:17 -0400 Subject: [PATCH 3/8] feat(web): lazy-load markdown syntax highlighter --- .../assistant-ui/markdown-code-block.tsx | 104 ++++++++++++++++++ .../components/assistant-ui/markdown-text.tsx | 99 ++++++----------- 2 files changed, 138 insertions(+), 65 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/markdown-code-block.tsx diff --git a/surfsense_web/components/assistant-ui/markdown-code-block.tsx b/surfsense_web/components/assistant-ui/markdown-code-block.tsx new file mode 100644 index 000000000..3c8d79ec9 --- /dev/null +++ b/surfsense_web/components/assistant-ui/markdown-code-block.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { CheckIcon, CopyIcon } from "lucide-react"; +import type { CSSProperties } from "react"; +import { memo, useEffect, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn, copyToClipboard } from "@/lib/utils"; + +type MarkdownCodeBlockProps = { + className?: string; + language: string; + codeText: string; + isDarkMode: boolean; +}; + +function stripThemeBackgrounds( + theme: Record +): Record { + const cleaned: Record = {}; + for (const key of Object.keys(theme)) { + const { background, backgroundColor, ...rest } = theme[key] as CSSProperties & { + background?: string; + backgroundColor?: string; + }; + cleaned[key] = rest; + } + return cleaned; +} + +const cleanMaterialDark = stripThemeBackgrounds(materialDark); +const cleanMaterialLight = stripThemeBackgrounds(materialLight); + +function MarkdownCodeBlockComponent({ + className, + language, + codeText, + isDarkMode, +}: MarkdownCodeBlockProps) { + const [hasCopied, setHasCopied] = useState(false); + + useEffect(() => { + if (!hasCopied) return; + const timer = setTimeout(() => setHasCopied(false), 2000); + return () => clearTimeout(timer); + }, [hasCopied]); + + return ( +
+
+ {language} + +
+ + + {codeText} + +
+ ); +} + +export const MarkdownCodeBlock = memo(MarkdownCodeBlockComponent); + +export function MarkdownCodeBlockSkeleton() { + return ( +
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 3d33463b2..d26364b6d 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -7,19 +7,17 @@ import { unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock, } from "@assistant-ui/react-markdown"; -import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react"; +import { ExternalLinkIcon } from "lucide-react"; +import dynamic from "next/dynamic"; import { useTheme } from "next-themes"; -import type { CSSProperties } from "react"; -import { type FC, memo, type ReactNode, useState } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { memo, type ReactNode } from "react"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image"; import "katex/dist/katex.min.css"; import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, @@ -30,22 +28,32 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib/utils"; -function stripThemeBackgrounds( - theme: Record -): Record { - const cleaned: Record = {}; - for (const key of Object.keys(theme)) { - const { background, backgroundColor, ...rest } = theme[key] as CSSProperties & { - background?: string; - backgroundColor?: string; - }; - cleaned[key] = rest; - } - return cleaned; +function MarkdownCodeBlockSkeleton() { + return ( +
+
+ + +
+
+ + + + +
+
+ ); } -const cleanMaterialDark = stripThemeBackgrounds(materialDark); -const cleanMaterialLight = stripThemeBackgrounds(materialLight); +const LazyMarkdownCodeBlock = dynamic( + () => import("./markdown-code-block").then((mod) => mod.MarkdownCodeBlock), + { + loading: () => , + } +); // Storage for URL citations replaced during preprocess to avoid GFM autolink interference. // Populated in preprocessMarkdown, consumed in parseTextWithCitations. @@ -178,39 +186,6 @@ const MarkdownTextImpl = () => { export const MarkdownText = memo(MarkdownTextImpl); -const InlineCodeHeader: FC<{ language: string; code: string }> = ({ language, code }) => { - const { isCopied, copyToClipboard } = useCopyToClipboard(); - const onCopy = () => { - if (!code || isCopied) return; - copyToClipboard(code); - }; - - return ( -
- {language} - - {!isCopied && } - {isCopied && } - -
- ); -}; - -const useCopyToClipboard = ({ copiedDuration = 3000 }: { copiedDuration?: number } = {}) => { - const [isCopied, setIsCopied] = useState(false); - - const copyToClipboard = (value: string) => { - if (!value) return; - - navigator.clipboard.writeText(value).then(() => { - setIsCopied(true); - setTimeout(() => setIsCopied(false), copiedDuration); - }); - }; - - return { isCopied, copyToClipboard }; -}; - /** * Helper to process children and replace citation patterns with components */ @@ -426,19 +401,13 @@ const defaultComponents = memoizeMarkdownComponents({ } const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text"; const codeString = String(children).replace(/\n$/, ""); - const syntaxStyle = resolvedTheme === "dark" ? cleanMaterialDark : cleanMaterialLight; return ( -
- - - {codeString} - -
+ ); }, strong: ({ className, children, ...props }) => ( From 6fe2f6fc90f686d8fd405330aad7d23bdcc8e9cb Mon Sep 17 00:00:00 2001 From: jupal Date: Sat, 28 Mar 2026 05:08:20 -0400 Subject: [PATCH 4/8] Add aria attributes to web search toggle --- surfsense_web/components/assistant-ui/thread.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 1644b0163..f460b4f5f 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -944,6 +944,8 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false {hasWebSearchTool && ( - diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index a295696d5..e13a08614 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -1,6 +1,15 @@ "use client"; -import { AlertCircle, Clock, Download, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react"; +import { + AlertCircle, + Clock, + Download, + Eye, + MoreHorizontal, + Move, + PenLine, + Trash2, +} from "lucide-react"; import React, { useCallback, useRef, useState } from "react"; import { useDrag } from "react-dnd"; import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; @@ -112,14 +121,15 @@ export const DocumentNode = React.memo(function DocumentNode({ return ( + {/* biome-ignore lint/a11y/useSemanticElements: contains nested interactive children (Checkbox) that render as diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index 5a76e882d..6a36f724f 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -15,7 +15,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import type { FolderSelectionState } from "./FolderTreeView"; import { ContextMenu, ContextMenuContent, @@ -29,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import type { FolderSelectionState } from "./FolderTreeView"; export const DND_TYPES = { FOLDER: "FOLDER", @@ -263,7 +263,9 @@ export const FolderNode = React.memo(function FolderNode({ e.stopPropagation()} className="h-3.5 w-3.5 shrink-0" diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index eec0aa7cc..d74c58d3e 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -33,6 +33,7 @@ interface FolderTreeViewProps { onMoveDocument: (doc: DocumentNodeDoc) => void; onExportDocument?: (doc: DocumentNodeDoc, format: string) => void; activeTypes: DocumentTypeEnum[]; + searchQuery?: string; onDropIntoFolder?: ( itemType: "folder" | "document", itemId: number, @@ -69,6 +70,7 @@ export function FolderTreeView({ onMoveDocument, onExportDocument, activeTypes, + searchQuery, onDropIntoFolder, onReorderFolder, }: FolderTreeViewProps) { @@ -97,13 +99,13 @@ export function FolderTreeView({ const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]); const hasDescendantMatch = useMemo(() => { - if (activeTypes.length === 0) return null; + if (activeTypes.length === 0 && !searchQuery) return null; const match: Record = {}; function check(folderId: number): boolean { if (match[folderId] !== undefined) return match[folderId]; - const childDocs = (docsByFolder[folderId] ?? []).some((d) => - activeTypes.includes(d.document_type as DocumentTypeEnum) + const childDocs = (docsByFolder[folderId] ?? []).some( + (d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum) ); if (childDocs) { match[folderId] = true; @@ -124,7 +126,7 @@ export function FolderTreeView({ check(f.id); } return match; - }, [folders, docsByFolder, foldersByParent, activeTypes]); + }, [folders, docsByFolder, foldersByParent, activeTypes, searchQuery]); const folderSelectionStates = useMemo(() => { const states: Record = {}; @@ -177,12 +179,15 @@ export function FolderTreeView({ after: i < visibleFolders.length - 1 ? visibleFolders[i + 1].position : null, }; + const isAutoExpanded = !!searchQuery && !!hasDescendantMatch?.[f.id]; + const isExpanded = expandedIds.has(f.id) || isAutoExpanded; + nodes.push( ); - if (expandedIds.has(f.id)) { + if (isExpanded) { nodes.push(...renderLevel(f.id, depth + 1)); } } @@ -240,7 +245,7 @@ export function FolderTreeView({ ); } - if (treeNodes.length === 0 && activeTypes.length > 0) { + if (treeNodes.length === 0 && (activeTypes.length > 0 || searchQuery)) { return (
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index f4ddf5ce5..ce9d93f60 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -2,42 +2,50 @@ import { useQuery } from "@rocicorp/zero/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { ChevronLeft, ChevronRight, Unplug } from "lucide-react"; +import { ChevronLeft, ChevronRight, Trash2, Unplug } from "lucide-react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; -import { - DocumentsTableShell, - type SortKey, -} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell"; import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; +import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; +import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import { useDocumentSearch } from "@/hooks/use-document-search"; -import { useDocuments } from "@/hooks/use-documents"; import { useMediaQuery } from "@/hooks/use-media-query"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { queries } from "@/zero/queries/index"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; +const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"]; + const SHOWCASE_CONNECTORS = [ { type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" }, { type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" }, @@ -82,8 +90,6 @@ export function DocumentsSidebar({ const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); - const [sortKey, setSortKey] = useState("created_at"); - const [sortDesc, setSortDesc] = useState(true); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); @@ -110,6 +116,7 @@ export function DocumentsSidebar({ // Zero queries for tree data const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId })); const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId })); + const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom); const treeFolders: FolderDisplay[] = useMemo( () => @@ -123,19 +130,41 @@ export function DocumentsSidebar({ [zeroFolders] ); - const treeDocuments: DocumentNodeDoc[] = useMemo( - () => - (zeroAllDocs ?? []) - .filter((d) => d.title && d.title.trim() !== "") - .map((d) => ({ - id: d.id, - title: d.title, - document_type: d.documentType, - folderId: (d as { folderId?: number | null }).folderId ?? null, - status: d.status as { state: string; reason?: string | null } | undefined, - })), - [zeroAllDocs] - ); + const treeDocuments: DocumentNodeDoc[] = useMemo(() => { + const zeroDocs = (zeroAllDocs ?? []) + .filter((d) => d.title && d.title.trim() !== "") + .map((d) => ({ + id: d.id, + title: d.title, + document_type: d.documentType, + folderId: (d as { folderId?: number | null }).folderId ?? null, + status: d.status as { state: string; reason?: string | null } | undefined, + })); + + const zeroIds = new Set(zeroDocs.map((d) => d.id)); + + const pendingAgentDocs = agentCreatedDocs + .filter((d) => d.searchSpaceId === searchSpaceId && !zeroIds.has(d.id)) + .map((d) => ({ + id: d.id, + title: d.title, + document_type: d.documentType, + folderId: d.folderId ?? null, + status: { state: "ready" } as { state: string; reason?: string | null }, + })); + + return [...pendingAgentDocs, ...zeroDocs]; + }, [zeroAllDocs, agentCreatedDocs, searchSpaceId]); + + // Prune agent-created docs once Zero has caught up + useEffect(() => { + if (!zeroAllDocs?.length || !agentCreatedDocs.length) return; + const zeroIds = new Set(zeroAllDocs.map((d) => d.id)); + const remaining = agentCreatedDocs.filter((d) => !zeroIds.has(d.id)); + if (remaining.length < agentCreatedDocs.length) { + setAgentCreatedDocs(remaining); + } + }, [zeroAllDocs, agentCreatedDocs, setAgentCreatedDocs]); const foldersByParent = useMemo(() => { const map: Record = {}; @@ -355,7 +384,7 @@ export function DocumentsSidebar({ (d) => d.folderId === parentId && d.status?.state !== "pending" && - d.status?.state !== "processing", + d.status?.state !== "processing" ); const childFolders = foldersByParent[String(parentId)] ?? []; const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id)); @@ -382,38 +411,72 @@ export function DocumentsSidebar({ setSidebarDocs((prev) => prev.filter((d) => !idsToRemove.has(d.id))); } }, - [treeDocuments, foldersByParent, setSidebarDocs], + [treeDocuments, foldersByParent, setSidebarDocs] ); - const isSearchMode = !!debouncedSearch.trim(); + const searchFilteredDocuments = useMemo(() => { + const query = debouncedSearch.trim().toLowerCase(); + if (!query) return treeDocuments; + return treeDocuments.filter((d) => d.title.toLowerCase().includes(query)); + }, [treeDocuments, debouncedSearch]); - const { - documents: realtimeDocuments, - typeCounts: realtimeTypeCounts, - loading: realtimeLoading, - loadingMore: realtimeLoadingMore, - hasMore: realtimeHasMore, - loadMore: realtimeLoadMore, - removeItems: realtimeRemoveItems, - error: realtimeError, - } = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc"); + const typeCounts = useMemo(() => { + const counts: Partial> = {}; + for (const d of treeDocuments) { + counts[d.document_type] = (counts[d.document_type] || 0) + 1; + } + return counts; + }, [treeDocuments]); - const { - documents: searchDocuments, - loading: searchLoading, - loadingMore: searchLoadingMore, - hasMore: searchHasMore, - loadMore: searchLoadMore, - error: searchError, - removeItems: searchRemoveItems, - } = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open); + const deletableSelectedIds = useMemo(() => { + const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d])); + return sidebarDocs + .filter((doc) => { + const fullDoc = treeDocMap.get(doc.id); + if (!fullDoc) return false; + const state = fullDoc.status?.state ?? "ready"; + return ( + state !== "pending" && + state !== "processing" && + !NON_DELETABLE_DOCUMENT_TYPES.includes(doc.document_type) + ); + }) + .map((doc) => doc.id); + }, [sidebarDocs, treeDocuments]); - const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments; - const loading = isSearchMode ? searchLoading : realtimeLoading; - const error = isSearchMode ? searchError : !!realtimeError; - const hasMore = isSearchMode ? searchHasMore : realtimeHasMore; - const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore; - const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore; + const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false); + const [isBulkDeleting, setIsBulkDeleting] = useState(false); + + const handleBulkDeleteSelected = useCallback(async () => { + if (deletableSelectedIds.length === 0) return; + setIsBulkDeleting(true); + try { + const results = await Promise.allSettled( + deletableSelectedIds.map(async (id) => { + await deleteDocumentMutation({ id }); + return id; + }) + ); + const successIds = results + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value); + const failed = results.length - successIds.length; + if (successIds.length > 0) { + setSidebarDocs((prev) => { + const idSet = new Set(successIds); + return prev.filter((d) => !idSet.has(d.id)); + }); + toast.success(`Deleted ${successIds.length} document${successIds.length !== 1 ? "s" : ""}`); + } + if (failed > 0) { + toast.error(`Failed to delete ${failed} document${failed !== 1 ? "s" : ""}`); + } + } catch { + toast.error("Failed to delete documents"); + } + setIsBulkDeleting(false); + setBulkDeleteConfirmOpen(false); + }, [deletableSelectedIds, deleteDocumentMutation, setSidebarDocs]); const onToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => { setActiveTypes((prev) => { @@ -430,69 +493,15 @@ export function DocumentsSidebar({ await deleteDocumentMutation({ id }); toast.success(t("delete_success") || "Document deleted"); setSidebarDocs((prev) => prev.filter((d) => d.id !== id)); - realtimeRemoveItems([id]); - if (isSearchMode) { - searchRemoveItems([id]); - } return true; } catch (e) { console.error("Error deleting document:", e); return false; } }, - [ - deleteDocumentMutation, - isSearchMode, - t, - searchRemoveItems, - realtimeRemoveItems, - setSidebarDocs, - ] + [deleteDocumentMutation, t, setSidebarDocs] ); - const handleBulkDeleteDocuments = useCallback( - async (ids: number[]): Promise<{ success: number; failed: number }> => { - const successIds: number[] = []; - const results = await Promise.allSettled( - ids.map(async (id) => { - await deleteDocumentMutation({ id }); - successIds.push(id); - }) - ); - if (successIds.length > 0) { - setSidebarDocs((prev) => prev.filter((d) => !successIds.includes(d.id))); - realtimeRemoveItems(successIds); - if (isSearchMode) { - searchRemoveItems(successIds); - } - } - const success = results.filter((r) => r.status === "fulfilled").length; - const failed = results.filter((r) => r.status === "rejected").length; - return { success, failed }; - }, - [deleteDocumentMutation, isSearchMode, searchRemoveItems, realtimeRemoveItems, setSidebarDocs] - ); - - const sortKeyRef = useRef(sortKey); - const sortDescRef = useRef(sortDesc); - sortKeyRef.current = sortKey; - sortDescRef.current = sortDesc; - - const handleSortChange = useCallback((key: SortKey) => { - const currentKey = sortKeyRef.current; - const currentDesc = sortDescRef.current; - - if (currentKey === key && currentDesc) { - setSortKey("created_at"); - setSortDesc(true); - } else if (currentKey === key) { - setSortDesc(true); - } else { - setSortKey(key); - setSortDesc(false); - } - }, []); - useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { @@ -627,7 +636,7 @@ export function DocumentsSidebar({
- {isSearchMode ? ( - 0} - /> - ) : ( - { - openDocumentTab({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - }} - onEditDocument={(doc) => { - openDocumentTab({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - }} - onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} - onMoveDocument={handleMoveDocument} - onExportDocument={handleExportDocument} - activeTypes={activeTypes} - onDropIntoFolder={handleDropIntoFolder} - onReorderFolder={handleReorderFolder} - /> + {deletableSelectedIds.length > 0 && ( +
+ +
)} + + { + openDocumentTab({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); + }} + onEditDocument={(doc) => { + openDocumentTab({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); + }} + onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} + onMoveDocument={handleMoveDocument} + onExportDocument={handleExportDocument} + activeTypes={activeTypes} + onDropIntoFolder={handleDropIntoFolder} + onReorderFolder={handleReorderFolder} + />
+ + !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} + > + + + + Delete {deletableSelectedIds.length} document + {deletableSelectedIds.length !== 1 ? "s" : ""}? + + + This action cannot be undone.{" "} + {deletableSelectedIds.length === 1 + ? "This document" + : `These ${deletableSelectedIds.length} documents`}{" "} + will be permanently deleted from your search space. + + + + Cancel + { + e.preventDefault(); + handleBulkDeleteSelected(); + }} + disabled={isBulkDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isBulkDeleting ? : "Delete"} + + + + ); diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 3cc7429ea..b5c002bdf 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -7,10 +7,10 @@ import { useTheme } from "next-themes"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; -import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts"; import { fetchThreads } from "@/lib/chat/thread-persistence"; interface TourStep { diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index f7d256a95..a7d622908 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -9,6 +9,7 @@ import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { PlateEditor } from "@/components/editor/plate-editor"; import { MarkdownViewer } from "@/components/markdown-viewer"; +import { EXPORT_FILE_EXTENSIONS, ExportDropdownItems } from "@/components/shared/ExportMenuItems"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { @@ -17,7 +18,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ExportDropdownItems, EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; diff --git a/surfsense_web/components/shared/ExportMenuItems.tsx b/surfsense_web/components/shared/ExportMenuItems.tsx index 69833a195..86bc12c42 100644 --- a/surfsense_web/components/shared/ExportMenuItems.tsx +++ b/surfsense_web/components/shared/ExportMenuItems.tsx @@ -1,8 +1,12 @@ "use client"; import { Loader2 } from "lucide-react"; -import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { ContextMenuItem } from "@/components/ui/context-menu"; +import { + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; export const EXPORT_FILE_EXTENSIONS: Record = { pdf: "pdf", @@ -36,9 +40,7 @@ export function ExportDropdownItems({ <> {showAllFormats && ( <> - - Documents - + Documents {exporting === "pdf" && } PDF (.pdf) diff --git a/surfsense_web/components/tool-ui/google-calendar/update-event.tsx b/surfsense_web/components/tool-ui/google-calendar/update-event.tsx index 661032628..a31e9e103 100644 --- a/surfsense_web/components/tool-ui/google-calendar/update-event.tsx +++ b/surfsense_web/components/tool-ui/google-calendar/update-event.tsx @@ -287,13 +287,9 @@ function ApprovalCard({ ? pendingEdits.end_datetime : null, new_location: - pendingEdits.location !== (event?.location ?? "") - ? pendingEdits.location || null - : null, + pendingEdits.location !== (event?.location ?? "") ? pendingEdits.location || null : null, new_attendees: - attendeesArr && attendeesArr.join(",") !== origAttendees.join(",") - ? attendeesArr - : null, + attendeesArr && attendeesArr.join(",") !== origAttendees.join(",") ? attendeesArr : null, }; } return { diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx index 6ca6550b5..9c4512c2a 100644 --- a/surfsense_web/contracts/enums/toolIcons.tsx +++ b/surfsense_web/contracts/enums/toolIcons.tsx @@ -1,7 +1,6 @@ import { BookOpen, Brain, - Database, FileText, Film, Globe, @@ -13,7 +12,6 @@ import { } from "lucide-react"; const TOOL_ICONS: Record = { - search_knowledge_base: Database, generate_podcast: Podcast, generate_video_presentation: Film, generate_report: FileText, diff --git a/surfsense_web/hooks/use-zero-document-type-counts.ts b/surfsense_web/hooks/use-zero-document-type-counts.ts index 84fdc2b0a..517445acf 100644 --- a/surfsense_web/hooks/use-zero-document-type-counts.ts +++ b/surfsense_web/hooks/use-zero-document-type-counts.ts @@ -13,9 +13,7 @@ export function useZeroDocumentTypeCounts( ): Record | undefined { const numericId = searchSpaceId != null ? Number(searchSpaceId) : null; - const [zeroDocuments] = useQuery( - queries.documents.bySpace({ searchSpaceId: numericId ?? -1 }) - ); + const [zeroDocuments] = useQuery(queries.documents.bySpace({ searchSpaceId: numericId ?? -1 })); return useMemo(() => { if (!zeroDocuments || numericId == null) return undefined;