diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 7143a2cdf..9ea8bc982 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -70,6 +70,7 @@ import { UserMessage } from "@/components/assistant-ui/user-message"; import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup"; import { DocumentMentionPicker, + promoteRecentMention, type DocumentMentionPickerRef, } from "../new-chat/document-mention-picker"; import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; @@ -768,6 +769,7 @@ const Composer: FC = () => { ); const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => { + const parsedSearchSpaceId = Number(search_space_id); const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? []; const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc))); @@ -775,6 +777,9 @@ const Composer: FC = () => { const key = getMentionDocKey(mention); if (editorDocKeys.has(key)) continue; editorRef.current?.insertMentionChip(mention); + if (Number.isFinite(parsedSearchSpaceId)) { + promoteRecentMention(parsedSearchSpaceId, mention); + } // Track within the loop so a duplicate-in-batch can't double-insert. editorDocKeys.add(key); } @@ -783,7 +788,7 @@ const Composer: FC = () => { // onChange — no second write path here. setMentionQuery(""); setSuggestionAnchorPoint(null); - }, []); + }, [search_space_id]); useEffect(() => { const editor = editorRef.current; diff --git a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx index d72cf1366..2909fbf86 100644 --- a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx +++ b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx @@ -31,7 +31,7 @@ function ComposerSuggestionPopoverContent({ onCloseAutoFocus?.(event); }} className={cn( - "w-[256px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[288px]", + "w-[232px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[264px]", "data-[state=open]:!animate-none data-[state=closed]:!animate-none data-[state=open]:!duration-0 data-[state=closed]:!duration-0", className )} @@ -47,14 +47,14 @@ const ComposerSuggestionList = React.forwardRef< >(({ className, ...props }, ref) => (
)); ComposerSuggestionList.displayName = "ComposerSuggestionList"; function ComposerSuggestionGroup({ className, ...props }: React.HTMLAttributes) { - return
; + return
; } function ComposerSuggestionGroupHeading({ @@ -63,7 +63,7 @@ function ComposerSuggestionGroupHeading({ }: React.HTMLAttributes) { return (
); @@ -78,12 +78,12 @@ function ComposerSuggestionHeader({ return (
- {icon ? {icon} : null} + {icon ? {icon} : null} {children}
); @@ -103,7 +103,7 @@ const ComposerSuggestionItem = React.forwardRef< variant="ghost" disabled={disabled} className={cn( - "h-auto w-full justify-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm font-normal transition-colors", + "h-auto w-full justify-start gap-1.5 rounded-md px-2 py-1 text-left text-xs font-normal transition-colors", disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", muted && !selected && "text-muted-foreground hover:bg-accent hover:text-accent-foreground", selected && "bg-accent text-accent-foreground", @@ -111,7 +111,7 @@ const ComposerSuggestionItem = React.forwardRef< )} {...props} > - {icon ? {icon} : null} + {icon ? {icon} : null} {children} )); @@ -119,7 +119,7 @@ ComposerSuggestionItem.displayName = "ComposerSuggestionItem"; function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps) { return ( -
+
); @@ -134,7 +134,7 @@ function ComposerSuggestionMessage({

-

- +
+
{["a", "b", "c", "d", "e"].map((id, index) => (
= 3 && "hidden sm:flex" )} > - + - - + +
))} diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index c6829507f..3b96a7cd2 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -11,7 +11,9 @@ import { Unplug, } from "lucide-react"; import { + Fragment, forwardRef, + type UIEvent, useCallback, useDeferredValue, useEffect, @@ -19,7 +21,6 @@ import { useRef, useState, } from "react"; -import type * as React from "react"; import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom"; import { useAtomValue } from "jotai"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; @@ -61,6 +62,8 @@ interface DocumentMentionPickerProps { const PAGE_SIZE = 20; const MIN_SEARCH_LENGTH = 2; const DEBOUNCE_MS = 100; +const RECENTS_LIMIT = 3; +const RECENTS_STORAGE_PREFIX = "surfsense:composer-mention-recents:v1:"; type BrowseView = | { kind: "root" } @@ -77,6 +80,89 @@ function isConnectorActive(connector: SearchSourceConnector) { return connector.is_active !== false; } +function isMentionedContextItem(value: unknown): value is MentionedDocumentInfo { + if (!value || typeof value !== "object") return false; + const item = value as Partial; + if (typeof item.id !== "number" || typeof item.title !== "string") return false; + if (item.kind === "doc") return typeof item.document_type === "string"; + if (item.kind === "folder") return true; + if (item.kind === "connector") { + return typeof item.connector_type === "string" && typeof item.account_name === "string"; + } + return false; +} + +function getRecentsStorageKey(searchSpaceId: number) { + return `${RECENTS_STORAGE_PREFIX}${searchSpaceId}`; +} + +function readRecentMentions(searchSpaceId: number): MentionedDocumentInfo[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(getRecentsStorageKey(searchSpaceId)); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isMentionedContextItem).slice(0, RECENTS_LIMIT); + } catch { + return []; + } +} + +function writeRecentMentions(searchSpaceId: number, mentions: MentionedDocumentInfo[]) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + getRecentsStorageKey(searchSpaceId), + JSON.stringify(mentions.slice(0, RECENTS_LIMIT)) + ); + } catch { + // Recents are optional UI state; storage failures should not block mention insertion. + } +} + +export function promoteRecentMention(searchSpaceId: number, mention: MentionedDocumentInfo) { + const mentionKey = getMentionDocKey(mention); + const next = [ + mention, + ...readRecentMentions(searchSpaceId).filter((item) => getMentionDocKey(item) !== mentionKey), + ].slice(0, RECENTS_LIMIT); + writeRecentMentions(searchSpaceId, next); + return next; +} + +function getMentionIcon(mention: MentionedDocumentInfo) { + if (mention.kind === "folder") return ; + if (mention.kind === "connector") { + return getConnectorIcon(mention.connector_type, "size-4") ?? ; + } + return getConnectorIcon(mention.document_type, "size-4"); +} + +function refreshRecentMention( + mention: MentionedDocumentInfo, + documents: Pick[], + folders: { id: number; name: string }[], + connectors: SearchSourceConnector[], + hasHydratedRecentDocs: boolean +): MentionedDocumentInfo | null { + if (mention.kind === "doc") { + const doc = documents.find( + (item) => item.id === mention.id && item.document_type === mention.document_type + ); + if (doc) return makeDocMention(doc); + return hasHydratedRecentDocs ? null : mention; + } + if (mention.kind === "folder") { + const folder = folders.find((item) => item.id === mention.id); + return folder ? makeFolderMention({ id: folder.id, title: folder.name }) : null; + } + const connector = connectors.find( + (item) => item.id === mention.id && item.connector_type === mention.connector_type + ); + return connector ? makeConnectorMention(connector) : null; +} + function useDebounced(value: T, delay = DEBOUNCE_MS) { const [debounced, setDebounced] = useState(value); const timeoutRef = useRef | undefined>(undefined); @@ -156,6 +242,9 @@ export const DocumentMentionPicker = forwardRef< const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); + const [recentMentions, setRecentMentions] = useState(() => + readRecentMentions(searchSpaceId) + ); const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId })); const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom); @@ -178,6 +267,10 @@ export const DocumentMentionPicker = forwardRef< if (hasSearch) setView({ kind: "root" }); }, [hasSearch]); + useEffect(() => { + setRecentMentions(readRecentMentions(searchSpaceId)); + }, [searchSpaceId]); + const titleSearchParams = useMemo( () => ({ search_space_id: searchSpaceId, @@ -226,24 +319,24 @@ export const DocumentMentionPicker = forwardRef< useEffect(() => { if (currentPage !== 0) return; - const combinedDocs: Pick[] = []; + const combinedDocs: Pick[] = []; - if (surfsenseDocs?.items) { - for (const doc of surfsenseDocs.items) { - combinedDocs.push({ - id: doc.id, - title: doc.title, - document_type: "SURFSENSE_DOCS", - }); + if (surfsenseDocs?.items) { + for (const doc of surfsenseDocs.items) { + combinedDocs.push({ + id: doc.id, + title: doc.title, + document_type: "SURFSENSE_DOCS", + }); + } } - } - if (titleSearchResults?.items) { - combinedDocs.push(...titleSearchResults.items); - setHasMore(titleSearchResults.has_more); - } + if (titleSearchResults?.items) { + combinedDocs.push(...titleSearchResults.items); + setHasMore(titleSearchResults.has_more); + } - setAccumulatedDocuments(filterBySearchTerm(combinedDocs)); + setAccumulatedDocuments(filterBySearchTerm(combinedDocs)); }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]); const loadNextPage = useCallback(async () => { @@ -299,6 +392,47 @@ export const DocumentMentionPicker = forwardRef< () => activeConnectors.map(makeConnectorMention), [activeConnectors] ); + const recentDocMentions = useMemo( + () => recentMentions.filter((mention) => mention.kind === "doc"), + [recentMentions] + ); + const recentDocIdsKey = useMemo( + () => recentDocMentions.map((mention) => mention.id).join(","), + [recentDocMentions] + ); + const { data: hydratedRecentDocs = [], isFetched: hasHydratedRecentDocs } = useQuery({ + queryKey: ["composer-mention-recent-docs", searchSpaceId, recentDocIdsKey], + queryFn: async () => { + const results = await Promise.allSettled( + recentDocMentions.map((mention) => documentsApiService.getDocument({ id: mention.id })) + ); + return results + .map((result) => (result.status === "fulfilled" ? result.value : null)) + .filter((doc): doc is Document => doc !== null); + }, + enabled: recentDocMentions.length > 0, + staleTime: 60 * 1000, + }); + const recentValidationDocuments = useMemo( + () => [...actualDocuments, ...hydratedRecentDocs], + [actualDocuments, hydratedRecentDocs] + ); + const visibleRecentMentions = useMemo( + () => + recentMentions + .map((mention) => + refreshRecentMention( + mention, + recentValidationDocuments, + zeroFolders ?? [], + activeConnectors, + hasHydratedRecentDocs + ) + ) + .filter((mention): mention is MentionedDocumentInfo => mention !== null) + .slice(0, RECENTS_LIMIT), + [activeConnectors, hasHydratedRecentDocs, recentMentions, recentValidationDocuments, zeroFolders] + ); const selectedKeys = useMemo( () => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))), @@ -313,10 +447,22 @@ export const DocumentMentionPicker = forwardRef< }, [initialSelectedDocuments, onSelectionChange, onDone] ); + const recentRootNodes = useMemo[]>( + () => + visibleRecentMentions.map((mention) => ({ + id: `recent:${getMentionDocKey(mention)}`, + label: mention.title, + icon: getMentionIcon(mention), + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + })), + [visibleRecentMentions, selectedKeys] + ); const rootNodes = useMemo[]>( () => { - const nodes: ComposerSuggestionNode[] = []; + const nodes: ComposerSuggestionNode[] = [...recentRootNodes]; if (showSurfsenseDocsRootRef.current) { nodes.push({ id: "surfsense-docs", @@ -350,7 +496,7 @@ export const DocumentMentionPicker = forwardRef< ); return nodes; }, - [activeConnectors.length] + [activeConnectors.length, recentRootNodes] ); const searchNodes = useMemo[]>(() => { @@ -519,10 +665,11 @@ export const DocumentMentionPicker = forwardRef< onBack: handleBack, ref, }); + const canLoadMoreDocuments = hasSearch || view.kind === "files-folders"; const handleScroll = useCallback( - (e: React.UIEvent) => { - if (view.kind === "connectors" || view.kind === "connector-type") return; + (e: UIEvent) => { + if (!canLoadMoreDocuments) return; const target = e.currentTarget; const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; @@ -530,7 +677,7 @@ export const DocumentMentionPicker = forwardRef< loadNextPage(); } }, - [hasMore, isLoadingMore, loadNextPage, view.kind] + [canLoadMoreDocuments, hasMore, isLoadingMore, loadNextPage] ); const actualLoading = @@ -564,15 +711,21 @@ export const DocumentMentionPicker = forwardRef< {title ? ( <> { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleBack(); + } + }} + className="cursor-pointer rounded-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" icon={ - + + + } > {title} @@ -586,31 +739,43 @@ export const DocumentMentionPicker = forwardRef< {hasSearch ? ( Suggested Context ) : null} - {visibleNodes.map((node, index) => ( - !node.disabled && handleNodeSelect(node)} - onMouseEnter={() => navigator.setHighlightedIndex(index)} - > - - - {node.label} + {!hasSearch && view.kind === "root" && recentRootNodes.length > 0 ? ( + Recents + ) : null} + {visibleNodes.map((node, index) => { + const showRecentsSeparator = + !hasSearch && + view.kind === "root" && + recentRootNodes.length > 0 && + index === recentRootNodes.length; + return ( + + {showRecentsSeparator ? : null} + !node.disabled && handleNodeSelect(node)} + onMouseEnter={() => navigator.setHighlightedIndex(index)} + > + + + {node.label} + + {node.subtitle ? ( + + {node.subtitle} + + ) : null} - {node.subtitle ? ( - - {node.subtitle} - - ) : null} - - {node.type === "branch" ? ( - - ) : null} - - ))} + {node.type === "branch" ? ( + + ) : null} + + + ); + })} ) : ( @@ -618,7 +783,7 @@ export const DocumentMentionPicker = forwardRef< )} - {isLoadingMore && ( + {canLoadMoreDocuments && isLoadingMore && (
diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index b8fba5b61..54d44662c 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -142,12 +142,12 @@ export const PromptPicker = forwardRef(funct if (el) itemRefs.current.set(index, el); else itemRefs.current.delete(index); }} - icon={} + icon={} selected={index === highlightedIndex} onClick={() => handleSelect(index)} onMouseEnter={() => setHighlightedIndex(index)} > - {action.name} + {action.name} ))} @@ -157,7 +157,7 @@ export const PromptPicker = forwardRef(funct if (el) itemRefs.current.set(createPromptIndex, el); else itemRefs.current.delete(createPromptIndex); }} - icon={} + icon={} muted selected={highlightedIndex === createPromptIndex} onClick={() => handleSelect(createPromptIndex)}