From 0d65a2e4e3b70fef0776487a13f7640624a81860 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 13:37:59 +0530 Subject: [PATCH 01/10] feat(web): implement composer suggestion popover and integrate with document mention picker --- .../components/assistant-ui/thread.tsx | 35 +- .../new-chat/composer-suggestion-popup.tsx | 158 ++++++++ .../new-chat/document-mention-picker.tsx | 374 ++++++++---------- .../components/new-chat/prompt-picker.tsx | 135 +++---- 4 files changed, 389 insertions(+), 313 deletions(-) create mode 100644 surfsense_web/components/new-chat/composer-suggestion-popup.tsx diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index c4f6fed05..990382bfd 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -65,6 +65,7 @@ import { } from "@/components/assistant-ui/inline-mention-editor"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { UserMessage } from "@/components/assistant-ui/user-message"; +import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup"; import { DocumentMentionPicker, type DocumentMentionPickerRef, @@ -90,6 +91,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Popover, PopoverAnchor } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; @@ -533,6 +535,11 @@ const Composer: FC = () => { } }, [showDocumentPopover]); + const handleDocumentPopoverOpenChange = useCallback((open: boolean) => { + setShowDocumentPopover(open); + if (!open) setMentionQuery(""); + }, []); + const handleActionTrigger = useCallback((query: string) => { setShowPromptPicker(true); setActionQuery(query); @@ -545,6 +552,11 @@ const Composer: FC = () => { } }, [showPromptPicker]); + const handlePromptPickerOpenChange = useCallback((open: boolean) => { + setShowPromptPicker(open); + if (!open) setActionQuery(""); + }, []); + const handleActionSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { let userText = editorRef.current?.getText() ?? ""; @@ -723,8 +735,9 @@ const Composer: FC = () => { currentUserId={currentUser?.id ?? null} members={members ?? []} /> - {showDocumentPopover && ( -
+ + + { initialSelectedDocuments={mentionedDocuments} externalSearch={mentionQuery} /> -
- )} - {showPromptPicker && ( -
+ + + + + { }} externalSearch={actionQuery} /> -
- )} + +
) { + return ( + { + event.preventDefault(); + onOpenAutoFocus?.(event); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + onCloseAutoFocus?.(event); + }} + className={cn( + "w-[280px] overflow-hidden rounded-xl border border-popover-border bg-popover p-0 text-popover-foreground shadow-2xl sm:w-[320px]", + className + )} + {...props} + /> + ); +} + +const ComposerSuggestionList = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +ComposerSuggestionList.displayName = "ComposerSuggestionList"; + +function ComposerSuggestionGroup({ className, ...props }: React.HTMLAttributes) { + return
; +} + +function ComposerSuggestionGroupHeading({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +const ComposerSuggestionItem = React.forwardRef< + HTMLButtonElement, + Omit, "variant"> & { + icon?: React.ReactNode; + selected?: boolean; + muted?: boolean; + } +>(({ className, children, icon, selected, muted, disabled, ...props }, ref) => ( + +)); +ComposerSuggestionItem.displayName = "ComposerSuggestionItem"; + +function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps) { + return ( +
+ +
+ ); +} + +function ComposerSuggestionMessage({ + className, + children, + variant = "muted", +}: React.HTMLAttributes & { variant?: "muted" | "destructive" }) { + return ( +
+

+ {children} +

+
+ ); +} + +function ComposerSuggestionSkeleton() { + return ( +
+
+ +
+ {["a", "b", "c", "d", "e"].map((id, index) => ( +
= 3 && "hidden sm:flex" + )} + > + + + + + + +
+ ))} +
+ ); +} + +export { + ComposerSuggestionPopoverContent, + ComposerSuggestionList, + ComposerSuggestionGroup, + ComposerSuggestionGroupHeading, + ComposerSuggestionItem, + ComposerSuggestionSeparator, + ComposerSuggestionMessage, + ComposerSuggestionSkeleton, +}; diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index c3b907266..d0f7fb67c 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -17,13 +17,20 @@ import { FOLDER_MENTION_DOCUMENT_TYPE, type MentionedDocumentInfo, } from "@/atoms/chat/mentioned-documents.atom"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; +import { + ComposerSuggestionGroup, + ComposerSuggestionGroupHeading, + ComposerSuggestionItem, + ComposerSuggestionList, + ComposerSuggestionMessage, + ComposerSuggestionSeparator, + ComposerSuggestionSkeleton, +} from "@/components/new-chat/composer-suggestion-popup"; +import { Spinner } from "@/components/ui/spinner"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; -import { cn } from "@/lib/utils"; import { queries } from "@/zero/queries"; export interface DocumentMentionPickerRef { @@ -427,221 +434,160 @@ export const DocumentMentionPicker = forwardRef< ); return ( -
- {/* Scrollable document list with responsive height */} -
- {actualLoading ? ( -
-
- + {actualLoading ? ( + + ) : actualDocuments.length > 0 || folderMentions.length > 0 ? ( + + {/* SurfSense Documentation */} + {surfsenseDocsList.length > 0 && ( + <> + SurfSense Docs + {surfsenseDocsList.map((doc) => { + const mention: MentionedDocumentInfo = { + id: doc.id, + title: doc.title, + document_type: doc.document_type, + kind: "doc", + }; + const docKey = getMentionDocKey(mention); + const isAlreadySelected = selectedKeys.has(docKey); + const selectableIndex = selectableMentions.findIndex( + (m) => getMentionDocKey(m) === docKey + ); + const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; + + return ( + { + if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el); + else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex); + }} + icon={getConnectorIcon(doc.document_type)} + selected={isHighlighted} + disabled={isAlreadySelected} + onClick={() => !isAlreadySelected && handleSelectMention(mention)} + onMouseEnter={() => { + if (!isAlreadySelected && selectableIndex >= 0) { + setHighlightedIndex(selectableIndex); + } + }} + > + + {doc.title} + + + ); + })} + + )} + + {/* User Documents */} + {userDocsList.length > 0 && ( + <> + {surfsenseDocsList.length > 0 && } + Your Documents + {userDocsList.map((doc) => { + const mention: MentionedDocumentInfo = { + id: doc.id, + title: doc.title, + document_type: doc.document_type, + kind: "doc", + }; + const docKey = getMentionDocKey(mention); + const isAlreadySelected = selectedKeys.has(docKey); + const selectableIndex = selectableMentions.findIndex( + (m) => getMentionDocKey(m) === docKey + ); + const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; + + return ( + { + if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el); + else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex); + }} + icon={getConnectorIcon(doc.document_type)} + selected={isHighlighted} + disabled={isAlreadySelected} + onClick={() => !isAlreadySelected && handleSelectMention(mention)} + onMouseEnter={() => { + if (!isAlreadySelected && selectableIndex >= 0) { + setHighlightedIndex(selectableIndex); + } + }} + > + + {doc.title} + + + ); + })} + + )} + + {/* Folders — single source of truth is Zero (same store + that powers the documents sidebar). Selecting a + folder inserts a folder chip whose path the agent + can walk with ``ls`` / ``find_documents``. */} + {folderMentions.length > 0 && ( + <> + {(surfsenseDocsList.length > 0 || userDocsList.length > 0) && ( + + )} + Folders + {folderMentions.map((folder) => { + const folderKey = getMentionDocKey(folder); + const isAlreadySelected = selectedKeys.has(folderKey); + const selectableIndex = selectableMentions.findIndex( + (m) => getMentionDocKey(m) === folderKey + ); + const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; + + return ( + { + if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el); + else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex); + }} + icon={} + selected={isHighlighted} + disabled={isAlreadySelected} + onClick={() => !isAlreadySelected && handleSelectMention(folder)} + onMouseEnter={() => { + if (!isAlreadySelected && selectableIndex >= 0) { + setHighlightedIndex(selectableIndex); + } + }} + > + + {folder.title} + + + ); + })} + + )} + + {/* Pagination loading indicator */} + {isLoadingMore && ( +
+
- {["a", "b", "c", "d", "e"].map((id, i) => ( -
= 3 && "hidden sm:flex" - )} - > - - - - - - -
- ))} -
- ) : actualDocuments.length > 0 || folderMentions.length > 0 ? ( -
- {/* SurfSense Documentation */} - {surfsenseDocsList.length > 0 && ( - <> -
- SurfSense Docs -
- {surfsenseDocsList.map((doc) => { - const mention: MentionedDocumentInfo = { - id: doc.id, - title: doc.title, - document_type: doc.document_type, - kind: "doc", - }; - const docKey = getMentionDocKey(mention); - const isAlreadySelected = selectedKeys.has(docKey); - const selectableIndex = selectableMentions.findIndex( - (m) => getMentionDocKey(m) === docKey - ); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; - - return ( - - ); - })} - - )} - - {/* User Documents */} - {userDocsList.length > 0 && ( - <> - {surfsenseDocsList.length > 0 && ( -
- )} -
- Your Documents -
- {userDocsList.map((doc) => { - const mention: MentionedDocumentInfo = { - id: doc.id, - title: doc.title, - document_type: doc.document_type, - kind: "doc", - }; - const docKey = getMentionDocKey(mention); - const isAlreadySelected = selectedKeys.has(docKey); - const selectableIndex = selectableMentions.findIndex( - (m) => getMentionDocKey(m) === docKey - ); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; - - return ( - - ); - })} - - )} - - {/* Folders — single source of truth is Zero (same store - that powers the documents sidebar). Selecting a - folder inserts a folder chip whose path the agent - can walk with ``ls`` / ``find_documents``. */} - {folderMentions.length > 0 && ( - <> - {(surfsenseDocsList.length > 0 || userDocsList.length > 0) && ( -
- )} -
Folders
- {folderMentions.map((folder) => { - const folderKey = getMentionDocKey(folder); - const isAlreadySelected = selectedKeys.has(folderKey); - const selectableIndex = selectableMentions.findIndex( - (m) => getMentionDocKey(m) === folderKey - ); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; - - return ( - - ); - })} - - )} - - {/* Pagination loading indicator */} - {isLoadingMore && ( -
-
-
- )} -
- ) : ( -
-

No matching documents

-
- )} -
-
+ )} + + ) : ( + No matching documents + )} + ); }); diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index 1cb9f80f5..b8fba5b61 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -15,9 +15,15 @@ import { } from "react"; import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; +import { + ComposerSuggestionGroup, + ComposerSuggestionGroupHeading, + ComposerSuggestionItem, + ComposerSuggestionList, + ComposerSuggestionMessage, + ComposerSuggestionSeparator, + ComposerSuggestionSkeleton, +} from "@/components/new-chat/composer-suggestion-popup"; export interface PromptPickerRef { selectHighlighted: () => void; @@ -119,91 +125,48 @@ export const PromptPicker = forwardRef(funct ); return ( -
-
- {isLoading ? ( -
-
- -
- {["a", "b", "c", "d", "e"].map((id, i) => ( -
= 3 && "hidden sm:flex" - )} - > - - - - - - -
- ))} -
- ) : isError ? ( -
-

Failed to load prompts

-
- ) : filtered.length === 0 ? ( -
-

No matching prompts

-
- ) : ( -
-
- Saved Prompts -
- {filtered.map((action, index) => ( - - ))} - -
- -
- )} -
-
+ {action.name} + + ))} + + + { + if (el) itemRefs.current.set(createPromptIndex, el); + else itemRefs.current.delete(createPromptIndex); + }} + icon={} + muted + selected={highlightedIndex === createPromptIndex} + onClick={() => handleSelect(createPromptIndex)} + onMouseEnter={() => setHighlightedIndex(createPromptIndex)} + > + Create prompt + + + )} + ); }); From d4459748384dd6ce9c5e9bce80d9ffa5ac6673c3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 14:36:46 +0530 Subject: [PATCH 02/10] feat(web): enhance inline mention editor and thread components with suggestion trigger info and anchor rect --- .../assistant-ui/inline-mention-editor.tsx | 54 ++++++- .../components/assistant-ui/thread.tsx | 141 ++++++++++++++---- .../new-chat/composer-suggestion-popup.tsx | 6 +- 3 files changed, 162 insertions(+), 39 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 2c8ad6263..f67fb36dc 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,6 +47,17 @@ export type MentionChipInput = { kind?: MentionKind; }; +export type SuggestionAnchorRect = { + left: number; + top: number; + bottom: number; +}; + +export type SuggestionTriggerInfo = { + query: string; + anchorRect: SuggestionAnchorRect | null; +}; + export interface InlineMentionEditorRef { focus: () => void; clear: () => void; @@ -73,9 +84,9 @@ export interface InlineMentionEditorRef { interface InlineMentionEditorProps { placeholder?: string; - onMentionTrigger?: (query: string) => void; + onMentionTrigger?: (trigger: SuggestionTriggerInfo) => void; onMentionClose?: () => void; - onActionTrigger?: (query: string) => void; + onActionTrigger?: (trigger: SuggestionTriggerInfo) => void; onActionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; @@ -299,6 +310,36 @@ function scanActiveTrigger(text: string, cursor: number) { return { triggerChar, query }; } +function rectToAnchor(rect: DOMRect): SuggestionAnchorRect { + return { + left: rect.left, + top: rect.top, + bottom: rect.bottom, + }; +} + +function getSelectionAnchorRect(root: HTMLElement | null): SuggestionAnchorRect | null { + if (!root || typeof window === "undefined") return null; + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !selection.anchorNode) return null; + if (!root.contains(selection.anchorNode)) return null; + + const range = selection.getRangeAt(0).cloneRange(); + const rect = range.getClientRects()[0] ?? range.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect); + + if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) { + const fallbackRange = range.cloneRange(); + fallbackRange.setStart(range.startContainer, range.startOffset - 1); + fallbackRange.setEnd(range.startContainer, range.startOffset); + const fallbackRect = fallbackRange.getClientRects()[0] ?? fallbackRange.getBoundingClientRect(); + if (fallbackRect.width > 0 || fallbackRect.height > 0) return rectToAnchor(fallbackRect); + } + + return null; +} + export const InlineMentionEditor = forwardRef( ( { @@ -360,14 +401,19 @@ export const InlineMentionEditor = forwardRef + ); +} + +function getComposerSuggestionAnchorPoint( + triggerRect: SuggestionAnchorRect | null, + side: "top" | "bottom" +): ComposerSuggestionAnchorPoint | null { + if (!triggerRect) return null; + return { + left: triggerRect.left, + top: side === "bottom" ? triggerRect.bottom : triggerRect.top, + }; +} + export const Thread: FC = () => { return ; }; @@ -411,6 +441,8 @@ const Composer: FC = () => { const [showPromptPicker, setShowPromptPicker] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [actionQuery, setActionQuery] = useState(""); + const [suggestionAnchorPoint, setSuggestionAnchorPoint] = + useState(null); const editorRef = useRef(null); const prevMentionedDocsRef = useRef>(new Map()); const documentPickerRef = useRef(null); @@ -491,6 +523,7 @@ const Composer: FC = () => { lastSeenSlideoutTickRef.current = slideoutOpenedTick; setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); }, [slideoutOpenedTick]); // Sync editor text into assistant-ui's composer and mirror the chip @@ -523,38 +556,65 @@ const Composer: FC = () => { [aui, setMentionedDocuments] ); - const handleMentionTrigger = useCallback((query: string) => { + const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => { + const anchorPoint = getComposerSuggestionAnchorPoint(trigger.anchorRect, "top"); + if (!anchorPoint) { + setShowDocumentPopover(false); + setMentionQuery(""); + setSuggestionAnchorPoint(null); + return; + } + setSuggestionAnchorPoint(anchorPoint); setShowDocumentPopover(true); - setMentionQuery(query); + setMentionQuery(trigger.query); }, []); const handleMentionClose = useCallback(() => { if (showDocumentPopover) { setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); } }, [showDocumentPopover]); const handleDocumentPopoverOpenChange = useCallback((open: boolean) => { setShowDocumentPopover(open); - if (!open) setMentionQuery(""); + if (!open) { + setMentionQuery(""); + setSuggestionAnchorPoint(null); + } }, []); - const handleActionTrigger = useCallback((query: string) => { + const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => { + const anchorPoint = getComposerSuggestionAnchorPoint( + trigger.anchorRect, + clipboardInitialText ? "bottom" : "top" + ); + if (!anchorPoint) { + setShowPromptPicker(false); + setActionQuery(""); + setSuggestionAnchorPoint(null); + return; + } + setSuggestionAnchorPoint(anchorPoint); setShowPromptPicker(true); - setActionQuery(query); - }, []); + setActionQuery(trigger.query); + }, [clipboardInitialText]); const handleActionClose = useCallback(() => { if (showPromptPicker) { setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); } }, [showPromptPicker]); const handlePromptPickerOpenChange = useCallback((open: boolean) => { setShowPromptPicker(open); - if (!open) setActionQuery(""); + if (!open) { + setActionQuery(""); + setSuggestionAnchorPoint(null); + } }, []); const handleActionSelect = useCallback( @@ -573,6 +633,7 @@ const Composer: FC = () => { aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); }, [actionQuery, aui] ); @@ -588,6 +649,7 @@ const Composer: FC = () => { aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); setClipboardInitialText(undefined); }, [clipboardInitialText, electronAPI, aui] @@ -616,6 +678,7 @@ const Composer: FC = () => { e.preventDefault(); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); return; } } @@ -639,6 +702,7 @@ const Composer: FC = () => { e.preventDefault(); setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); return; } } @@ -699,6 +763,7 @@ const Composer: FC = () => { // Atom is reconciled by ``handleEditorChange`` via the editor's // onChange — no second write path here. setMentionQuery(""); + setSuggestionAnchorPoint(null); }, []); useEffect(() => { @@ -736,34 +801,44 @@ const Composer: FC = () => { members={members ?? []} /> - - - { - setShowDocumentPopover(false); - setMentionQuery(""); - }} - initialSelectedDocuments={mentionedDocuments} - externalSearch={mentionQuery} - /> - + {suggestionAnchorPoint ? ( + <> + + + { + setShowDocumentPopover(false); + setMentionQuery(""); + setSuggestionAnchorPoint(null); + }} + initialSelectedDocuments={mentionedDocuments} + externalSearch={mentionQuery} + /> + + + ) : null} - - - { - setShowPromptPicker(false); - setActionQuery(""); - }} - externalSearch={actionQuery} - /> - + {suggestionAnchorPoint ? ( + <> + + + { + setShowPromptPicker(false); + setActionQuery(""); + setSuggestionAnchorPoint(null); + }} + externalSearch={actionQuery} + /> + + + ) : null}
{ event.preventDefault(); onOpenAutoFocus?.(event); @@ -28,7 +30,7 @@ function ComposerSuggestionPopoverContent({ onCloseAutoFocus?.(event); }} className={cn( - "w-[280px] overflow-hidden rounded-xl border border-popover-border bg-popover p-0 text-popover-foreground shadow-2xl sm:w-[320px]", + "w-[280px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[320px]", className )} {...props} From 9cd3de9ec1ba1e2fc1331feefc163ac4be5aeb49 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 16:50:40 +0530 Subject: [PATCH 03/10] feat(web): update mention components to use Button component and enhance styling --- .../components/assistant-ui/inline-mention-editor.tsx | 11 +++++++---- .../components/assistant-ui/mention-chip.tsx | 9 ++++++--- surfsense_web/components/assistant-ui/thread.tsx | 6 +++--- .../components/new-chat/composer-suggestion-popup.tsx | 3 +++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index f67fb36dc..67466532e 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -21,6 +21,7 @@ import { useRef, } from "react"; import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom"; +import { Button } from "@/components/ui/button"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; @@ -160,8 +161,10 @@ const MentionElement: FC> = ({ )} {ctx ? ( - + + ) : null} diff --git a/surfsense_web/components/assistant-ui/mention-chip.tsx b/surfsense_web/components/assistant-ui/mention-chip.tsx index 7197fccdc..526d4b8b4 100644 --- a/surfsense_web/components/assistant-ui/mention-chip.tsx +++ b/surfsense_web/components/assistant-ui/mention-chip.tsx @@ -1,6 +1,7 @@ "use client"; import type { MouseEventHandler, ReactNode } from "react"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -60,13 +61,15 @@ export function MentionChip({ const isInteractive = Boolean(onClick) && !disabled; const chip = ( - + ); if (!tooltip) return chip; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 39e1b4360..72fb2690b 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -564,7 +564,7 @@ const Composer: FC = () => { setSuggestionAnchorPoint(null); return; } - setSuggestionAnchorPoint(anchorPoint); + setSuggestionAnchorPoint((current) => current ?? anchorPoint); setShowDocumentPopover(true); setMentionQuery(trigger.query); }, []); @@ -596,7 +596,7 @@ const Composer: FC = () => { setSuggestionAnchorPoint(null); return; } - setSuggestionAnchorPoint(anchorPoint); + setSuggestionAnchorPoint((current) => current ?? anchorPoint); setShowPromptPicker(true); setActionQuery(trigger.query); }, [clipboardInitialText]); @@ -866,7 +866,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} - className="min-h-[24px]" + className="min-h-[24px] **:data-slate-placeholder:font-semibold" />
diff --git a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx index d3e7b75bb..fd46d8ee7 100644 --- a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx +++ b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx @@ -14,6 +14,7 @@ function ComposerSuggestionPopoverContent({ collisionPadding = 12, onOpenAutoFocus, onCloseAutoFocus, + style, ...props }: React.ComponentProps) { return ( @@ -31,8 +32,10 @@ function ComposerSuggestionPopoverContent({ }} className={cn( "w-[280px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[320px]", + "data-[state=open]:!animate-none data-[state=closed]:!animate-none data-[state=open]:!duration-0 data-[state=closed]:!duration-0", className )} + style={{ ...style, animation: "none" }} {...props} /> ); From 701ae800b48fba44bd3443014737c0a31479985d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 18:27:02 +0530 Subject: [PATCH 04/10] feat(web): refactor sign-in button and composer components to use Button component --- surfsense_web/components/assistant-ui/thread.tsx | 9 +++++---- surfsense_web/components/auth/sign-in-button.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 72fb2690b..a68560d23 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -866,7 +866,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} - className="min-h-[24px] **:data-slate-placeholder:font-semibold" + className="min-h-[24px] **:data-slate-placeholder:font-normal" />
@@ -1121,9 +1121,10 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false >
- + { if (isGoogleAuth) { return ( - + ); } From a41b16b73e29758c5ab4722787d49c3bedc81f14 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 21:11:53 +0530 Subject: [PATCH 05/10] feat(web): enhance chat context and mention handling with connector support --- .../app/agents/new_chat/context.py | 2 + .../app/agents/new_chat/mention_resolver.py | 2 +- .../app/routes/new_chat_routes.py | 14 + surfsense_backend/app/schemas/new_chat.py | 21 +- .../app/tasks/chat/persistence.py | 22 +- .../app/tasks/chat/stream_new_chat.py | 30 + .../new-chat/[[...chat_id]]/page.tsx | 72 +- .../atoms/chat/mentioned-documents.atom.ts | 50 +- .../assistant-ui/inline-mention-editor.tsx | 26 +- .../components/assistant-ui/thread.tsx | 45 +- .../components/assistant-ui/user-message.tsx | 19 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 5 +- .../new-chat/document-mention-picker.tsx | 792 +++++++++--------- .../use-composer-suggestion-navigator.ts | 120 +++ surfsense_web/lib/chat/mention-doc-key.ts | 2 +- 15 files changed, 773 insertions(+), 449 deletions(-) create mode 100644 surfsense_web/components/new-chat/use-composer-suggestion-navigator.ts diff --git a/surfsense_backend/app/agents/new_chat/context.py b/surfsense_backend/app/agents/new_chat/context.py index a20a43a66..1b3ea3d20 100644 --- a/surfsense_backend/app/agents/new_chat/context.py +++ b/surfsense_backend/app/agents/new_chat/context.py @@ -64,6 +64,8 @@ class SurfSenseContextSchema: search_space_id: int | None = None mentioned_document_ids: list[int] = field(default_factory=list) mentioned_folder_ids: list[int] = field(default_factory=list) + mentioned_connector_ids: list[int] = field(default_factory=list) + mentioned_connectors: list[dict[str, object]] = field(default_factory=list) file_operation_contract: FileOperationContractState | None = None turn_id: str | None = None request_id: str | None = None diff --git a/surfsense_backend/app/agents/new_chat/mention_resolver.py b/surfsense_backend/app/agents/new_chat/mention_resolver.py index 00bb7e71f..6a025b947 100644 --- a/surfsense_backend/app/agents/new_chat/mention_resolver.py +++ b/surfsense_backend/app/agents/new_chat/mention_resolver.py @@ -134,7 +134,7 @@ async def resolve_mentions( kind = chip.kind if kind == "folder": chip_folder_ids.append(chip.id) - else: + elif kind == "doc": chip_doc_ids.append(chip.id) chip_titles_by_id[(kind, chip.id)] = chip.title diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 44fc1c392..fb4d5a049 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -1771,6 +1771,11 @@ async def handle_new_chat( if request.mentioned_documents else None ) + mentioned_connectors_payload = ( + [doc.model_dump() for doc in request.mentioned_connectors] + if request.mentioned_connectors + else None + ) return StreamingResponse( stream_new_chat( @@ -1782,6 +1787,8 @@ async def handle_new_chat( mentioned_document_ids=request.mentioned_document_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, mentioned_folder_ids=request.mentioned_folder_ids, + mentioned_connector_ids=request.mentioned_connector_ids, + mentioned_connectors=mentioned_connectors_payload, mentioned_documents=mentioned_documents_payload, needs_history_bootstrap=thread.needs_history_bootstrap, thread_visibility=thread.visibility, @@ -2258,6 +2265,11 @@ async def regenerate_response( if request.mentioned_documents else None ) + mentioned_connectors_payload = ( + [doc.model_dump() for doc in request.mentioned_connectors] + if request.mentioned_connectors + else None + ) try: async for chunk in stream_new_chat( user_query=str(user_query_to_use), @@ -2268,6 +2280,8 @@ async def regenerate_response( mentioned_document_ids=request.mentioned_document_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, mentioned_folder_ids=request.mentioned_folder_ids, + mentioned_connector_ids=request.mentioned_connector_ids, + mentioned_connectors=mentioned_connectors_payload, mentioned_documents=mentioned_documents_payload, checkpoint_id=target_checkpoint_id, needs_history_bootstrap=thread.needs_history_bootstrap, diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index c5315cce5..c721f495e 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -218,17 +218,20 @@ class MentionedDocumentInfo(BaseModel): id: int title: str = Field(..., min_length=1, max_length=500) document_type: str = Field(..., min_length=1, max_length=100) - kind: Literal["doc", "folder"] = Field( + kind: Literal["doc", "folder", "connector"] = Field( default="doc", description=( "Discriminator for the chip's referent: ``doc`` is a " "knowledge-base ``Document`` row, ``folder`` is a " - "knowledge-base ``Folder`` row. Folders carry the sentinel " + "knowledge-base ``Folder`` row, and ``connector`` is a " + "concrete connected account. Folders carry the sentinel " "``document_type='FOLDER'`` to keep the frontend dedup key " "``(kind:document_type:id)`` from colliding doc and folder " "ids that happen to share an integer value." ), ) + connector_type: str | None = Field(default=None, max_length=100) + account_name: str | None = Field(default=None, max_length=255) class NewChatRequest(BaseModel): @@ -266,6 +269,18 @@ class NewChatRequest(BaseModel): "a mentioned-documents part." ), ) + mentioned_connector_ids: list[int] | None = Field( + default=None, + description="Optional concrete connector account IDs the user @-mentioned.", + ) + mentioned_connectors: list[MentionedDocumentInfo] | None = Field( + default=None, + description=( + "Display/context metadata for selected connector accounts. " + "Kept separate from document/folder id arrays so tools can " + "prefer the exact account the user selected." + ), + ) disabled_tools: list[str] | None = ( None # Optional list of tool names the user has disabled from the UI ) @@ -335,6 +350,8 @@ class RegenerateRequest(BaseModel): "new user message. None means no chip metadata." ), ) + mentioned_connector_ids: list[int] | None = None + mentioned_connectors: list[MentionedDocumentInfo] | None = None disabled_tools: list[str] | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" diff --git a/surfsense_backend/app/tasks/chat/persistence.py b/surfsense_backend/app/tasks/chat/persistence.py index 37be50705..07266cf69 100644 --- a/surfsense_backend/app/tasks/chat/persistence.py +++ b/surfsense_backend/app/tasks/chat/persistence.py @@ -137,15 +137,19 @@ def _build_user_content( if doc_id is None or title is None or document_type is None: continue kind_raw = doc.get("kind", "doc") - kind = kind_raw if kind_raw in ("doc", "folder") else "doc" - normalized.append( - { - "id": doc_id, - "title": str(title), - "document_type": str(document_type), - "kind": kind, - } - ) + kind = kind_raw if kind_raw in ("doc", "folder", "connector") else "doc" + item = { + "id": doc_id, + "title": str(title), + "document_type": str(document_type), + "kind": kind, + } + if kind == "connector": + connector_type = doc.get("connector_type") or document_type + account_name = doc.get("account_name") or title + item["connector_type"] = str(connector_type) + item["account_name"] = str(account_name) + normalized.append(item) if normalized: parts.append({"type": "mentioned-documents", "documents": normalized}) return parts diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index fee50d72d..81c801959 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -839,6 +839,8 @@ async def stream_new_chat( mentioned_document_ids: list[int] | None = None, mentioned_surfsense_doc_ids: list[int] | None = None, mentioned_folder_ids: list[int] | None = None, + mentioned_connector_ids: list[int] | None = None, + mentioned_connectors: list[dict[str, Any]] | None = None, mentioned_documents: list[dict[str, Any]] | None = None, checkpoint_id: str | None = None, needs_history_bootstrap: bool = False, @@ -1385,6 +1387,32 @@ async def stream_new_chat( format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs) ) + if mentioned_connectors: + connector_lines = [] + for connector in mentioned_connectors: + if not isinstance(connector, dict): + continue + connector_id = connector.get("id") + connector_type = connector.get("connector_type") or connector.get( + "document_type" + ) + account_name = connector.get("account_name") or connector.get("title") + if connector_id is None or connector_type is None: + continue + connector_lines.append( + f' - connector_id={connector_id}, connector_type="{connector_type}", ' + f'account="{account_name or ""}"' + ) + if connector_lines: + context_parts.append( + "\n" + "The user selected these exact connector accounts with @. " + "For read, write, or HITL tool calls involving these services, " + "prefer the matching connector_id instead of guessing from available accounts:\n" + + "\n".join(connector_lines) + + "\n" + ) + # Surface report IDs prominently so the LLM doesn't have to # retrieve them from old tool responses in conversation history. if recent_reports: @@ -1778,6 +1806,8 @@ async def stream_new_chat( mentioned_folder_ids=list( accepted_folder_ids or mentioned_folder_ids or [] ), + mentioned_connector_ids=list(mentioned_connector_ids or []), + mentioned_connectors=list(mentioned_connectors or []), request_id=request_id, turn_id=stream_result.turn_id, ) 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 ecd5ab6b1..8d1f5da46 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 @@ -208,9 +208,11 @@ const MentionedDocumentInfoSchema = z.object({ title: z.string(), document_type: z.string(), kind: z - .union([z.literal("doc"), z.literal("folder")]) + .union([z.literal("doc"), z.literal("folder"), z.literal("connector")]) .optional() .default("doc"), + connector_type: z.string().optional(), + account_name: z.string().optional(), }); const MentionedDocumentsPartSchema = z.object({ @@ -227,7 +229,32 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { for (const part of content) { const result = MentionedDocumentsPartSchema.safeParse(part); if (result.success) { - return result.data.documents; + return result.data.documents.map((doc) => { + if (doc.kind === "connector") { + return { + id: doc.id, + title: doc.title, + document_type: doc.document_type, + kind: "connector", + connector_type: doc.connector_type ?? doc.document_type, + account_name: doc.account_name ?? doc.title, + }; + } + if (doc.kind === "folder") { + return { + id: doc.id, + title: doc.title, + document_type: "FOLDER", + kind: "folder", + }; + } + return { + id: doc.id, + title: doc.title, + document_type: doc.document_type, + kind: "doc", + }; + }); } } @@ -924,7 +951,8 @@ export default function NewChatPage() { hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0 || - mentionedDocumentIds.folder_ids.length > 0, + mentionedDocumentIds.folder_ids.length > 0 || + mentionedDocumentIds.connector_ids.length > 0, messageLength: userQuery.length, }); @@ -940,12 +968,7 @@ export default function NewChatPage() { const key = `${doc.kind}:${doc.document_type}:${doc.id}`; if (seenDocKeys.has(key)) continue; seenDocKeys.add(key); - allMentionedDocs.push({ - id: doc.id, - title: doc.title, - document_type: doc.document_type, - kind: doc.kind, - }); + allMentionedDocs.push(doc); } if (allMentionedDocs.length > 0) { @@ -1008,9 +1031,10 @@ export default function NewChatPage() { const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0; const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0; const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0; + const hasConnectorIds = mentionedDocumentIds.connector_ids.length > 0; // Clear mentioned documents after capturing them - if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds) { + if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds || hasConnectorIds) { setMentionedDocuments([]); } @@ -1036,20 +1060,16 @@ export default function NewChatPage() { ? mentionedDocumentIds.surfsense_doc_ids : undefined, mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined, + mentioned_connector_ids: hasConnectorIds + ? mentionedDocumentIds.connector_ids + : undefined, + mentioned_connectors: hasConnectorIds ? mentionedDocumentIds.connectors : undefined, // Full mention metadata (docs + folders, with // ``kind`` discriminator) so the BE can embed a // ``mentioned-documents`` ContentPart on the // persisted user message (replaces the old FE-side // injection in ``persistUserTurn``). - mentioned_documents: - allMentionedDocs.length > 0 - ? allMentionedDocs.map((d) => ({ - id: d.id, - title: d.title, - document_type: d.document_type, - kind: d.kind, - })) - : undefined, + mentioned_documents: allMentionedDocs.length > 0 ? allMentionedDocs : undefined, disabled_tools: disabledTools.length > 0 ? disabledTools : undefined, ...(userImages.length > 0 ? { user_images: userImages } : {}), }), @@ -1945,6 +1965,7 @@ export default function NewChatPage() { const regenerateFolderIds = sourceMentionedDocs .filter((d) => d.kind === "folder") .map((d) => d.id); + const regenerateConnectors = sourceMentionedDocs.filter((d) => d.kind === "connector"); const requestBody: Record = { search_space_id: searchSpaceId, @@ -1957,19 +1978,16 @@ export default function NewChatPage() { mentioned_surfsense_doc_ids: regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined, mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined, + mentioned_connector_ids: + regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined, + mentioned_connectors: + regenerateConnectors.length > 0 ? regenerateConnectors : undefined, // Full mention metadata for the regenerate-specific // source list. Only meaningful for edit (the BE only // re-persists a user row when ``user_query`` is set); // reload reuses the original turn's mentioned_documents. mentioned_documents: - sourceMentionedDocs.length > 0 - ? sourceMentionedDocs.map((d) => ({ - id: d.id, - title: d.title, - document_type: d.document_type, - kind: d.kind, - })) - : undefined, + sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined, }; if (isEdit) { requestBody.user_images = editExtras?.userImages ?? []; diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 9163960f4..9efd2b7fe 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -13,18 +13,31 @@ export const FOLDER_MENTION_DOCUMENT_TYPE = "FOLDER"; /** * Display metadata for a single ``@``-mention chip. * - * The ``kind`` discriminator identifies whether the chip is a - * knowledge-base document or a knowledge-base folder. Folders carry - * the sentinel ``document_type === FOLDER_MENTION_DOCUMENT_TYPE`` so - * the editor, picker, and persisted ``mentioned-documents`` content - * part all stay aligned with the backend Pydantic schema. + * Historical name is retained because this atom is already wired into + * chat persistence and sidebar selection. The shape is now the selected + * composer context, not only documents. */ -export interface MentionedDocumentInfo { - id: number; - title: string; - document_type: string; - kind: "doc" | "folder"; -} +export type MentionedDocumentInfo = + | { + id: number; + title: string; + document_type: string; + kind: "doc"; + } + | { + id: number; + title: string; + document_type: typeof FOLDER_MENTION_DOCUMENT_TYPE; + kind: "folder"; + } + | { + id: number; + title: string; + document_type: string; + kind: "connector"; + connector_type: string; + account_name: string; + }; /** * Backwards-compatible doc-only chip shape for legacy callers that @@ -44,7 +57,10 @@ type LegacyDocMention = Pick; export function toMentionedDocumentInfo( input: LegacyDocMention | MentionedDocumentInfo ): MentionedDocumentInfo { - if ("kind" in input && (input.kind === "doc" || input.kind === "folder")) { + if ( + "kind" in input && + (input.kind === "doc" || input.kind === "folder" || input.kind === "connector") + ) { return input; } return { @@ -93,12 +109,22 @@ export const mentionedDocumentIdsAtom = atom((get) => { }); const docs = deduped.filter((m) => m.kind === "doc"); const folders = deduped.filter((m) => m.kind === "folder"); + const connectors = deduped.filter((m) => m.kind === "connector"); return { surfsense_doc_ids: docs .filter((doc) => doc.document_type === "SURFSENSE_DOCS") .map((doc) => doc.id), document_ids: docs.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), folder_ids: folders.map((f) => f.id), + connector_ids: connectors.map((c) => c.id), + connectors: connectors.map((c) => ({ + id: c.id, + title: c.title, + document_type: c.document_type, + kind: c.kind, + connector_type: c.connector_type, + account_name: c.account_name, + })), }; }); diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 67466532e..b93ea253d 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { Folder as FolderIcon, X as XIcon } from "lucide-react"; +import { Folder as FolderIcon, Plug as PlugIcon, X as XIcon } from "lucide-react"; import type { NodeEntry, TElement } from "platejs"; import type { PlateElementProps } from "platejs/react"; import { @@ -27,13 +27,15 @@ import type { Document } from "@/contracts/types/document.types"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { cn } from "@/lib/utils"; -export type MentionKind = "doc" | "folder"; +export type MentionKind = "doc" | "folder" | "connector"; export interface MentionedDocument { id: number; title: string; document_type?: string; kind: MentionKind; + connector_type?: string; + account_name?: string; } /** @@ -46,6 +48,8 @@ export type MentionChipInput = { title: string; document_type?: string; kind?: MentionKind; + connector_type?: string; + account_name?: string; }; export type SuggestionAnchorRect = { @@ -107,6 +111,8 @@ type MentionElementNode = { document_type?: string; /** Discriminator; defaults to ``"doc"`` for legacy nodes. */ kind?: MentionKind; + connector_type?: string; + account_name?: string; statusLabel?: string | null; statusKind?: MentionStatusKind; children: [{ text: "" }]; @@ -146,6 +152,7 @@ const MentionElement: FC> = ({ : "text-amber-700"; const isFolder = element.kind === "folder"; + const isConnector = element.kind === "connector"; const ctx = useContext(MentionEditorContext); return ( @@ -156,6 +163,10 @@ const MentionElement: FC> = ({ {isFolder ? ( + ) : isConnector ? ( + getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? ( + + ) ) : ( getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3") )} @@ -242,6 +253,8 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] { title: node.title, document_type: node.document_type, kind, + connector_type: node.connector_type, + account_name: node.account_name, }; map.set(getMentionDocKey(doc), doc); } @@ -444,13 +457,20 @@ export const InlineMentionEditor = forwardRef { return prev; } } - return docs.map((d) => ({ - id: d.id, - title: d.title, - // Atom requires a string; ``"UNKNOWN"`` matches the - // sentinel ``getMentionDocKey`` and the editor's - // match predicates use. - document_type: d.document_type ?? "UNKNOWN", - kind: d.kind, - })); + return docs.map((d) => { + const documentType = d.document_type ?? "UNKNOWN"; + if (d.kind === "connector") { + return { + id: d.id, + title: d.title, + document_type: documentType, + kind: "connector", + connector_type: d.connector_type ?? documentType, + account_name: d.account_name ?? d.title, + }; + } + if (d.kind === "folder") { + return { + id: d.id, + title: d.title, + document_type: FOLDER_MENTION_DOCUMENT_TYPE, + kind: "folder", + }; + } + return { + id: d.id, + title: d.title, + // Atom requires a string; ``"UNKNOWN"`` matches the + // sentinel ``getMentionDocKey`` and the editor's + // match predicates use. + document_type: documentType, + kind: "doc", + }; + }); }); }, [aui, setMentionedDocuments] @@ -700,6 +722,9 @@ const Composer: FC = () => { } if (e.key === "Escape") { e.preventDefault(); + if (documentPickerRef.current?.goBack()) { + return; + } setShowDocumentPopover(false); setMentionQuery(""); setSuggestionAnchorPoint(null); diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index d17788c71..3e6dc829a 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -6,7 +6,7 @@ import { useMessagePartText, } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; -import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react"; +import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil, Plug } from "lucide-react"; import Image from "next/image"; import { useParams } from "next/navigation"; import { type FC, useCallback, useState } from "react"; @@ -100,8 +100,13 @@ const UserTextPart: FC = () => { return {segment.value}; } const isFolder = segment.doc.kind === "folder"; + const isConnector = segment.doc.kind === "connector"; const icon = isFolder ? ( + ) : isConnector ? ( + getConnectorIcon(segment.doc.connector_type ?? segment.doc.document_type, "size-3.5") ?? ( + + ) ) : ( getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5") ); @@ -110,8 +115,16 @@ const UserTextPart: FC = () => { key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`} icon={icon} label={segment.doc.title} - tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title} - onClick={isFolder ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)} + tooltip={ + isFolder + ? `Folder: ${segment.doc.title}` + : isConnector + ? `Connector account: ${segment.doc.title}` + : segment.doc.title + } + onClick={ + isFolder || isConnector ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title) + } className="mx-0.5" /> ); diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 503ca239c..ca90ba9b9 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -1063,6 +1063,7 @@ function AuthenticatedDocumentsSidebarBase({ const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d])); return sidebarDocs .filter((doc) => { + if (doc.kind !== "doc") return false; const fullDoc = treeDocMap.get(doc.id); if (!fullDoc) return false; const state = fullDoc.status?.state ?? "ready"; @@ -1124,7 +1125,7 @@ function AuthenticatedDocumentsSidebarBase({ try { await deleteDocumentMutation({ id }); toast.success(t("delete_success") || "Document deleted"); - setSidebarDocs((prev) => prev.filter((d) => d.id !== id)); + setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== id)); return true; } catch (e) { console.error("Error deleting document:", e); @@ -1953,7 +1954,7 @@ function AnonymousDocumentsSidebar({ onEditDocument={() => gate("edit documents")} onDeleteDocument={async () => { handleRemoveDoc(); - setSidebarDocs((prev) => prev.filter((d) => d.id !== -1)); + setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1)); return true; }} onMoveDocument={() => gate("organize documents")} diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index d0f7fb67c..f8a84c51b 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -2,21 +2,35 @@ import { useQuery as useZeroQuery } from "@rocicorp/zero/react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Folder as FolderIcon } from "lucide-react"; +import { + BookOpen, + ChevronLeft, + ChevronRight, + Files, + Folder as FolderIcon, + Plug, +} from "lucide-react"; import { forwardRef, useCallback, useDeferredValue, useEffect, - useImperativeHandle, useMemo, useRef, useState, } from "react"; +import type * as React from "react"; import { FOLDER_MENTION_DOCUMENT_TYPE, type MentionedDocumentInfo, } from "@/atoms/chat/mentioned-documents.atom"; +import { useAtomValue } from "jotai"; +import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; +import { + COMPOSIO_CONNECTORS, + OAUTH_CONNECTORS, +} from "@/components/assistant-ui/connector-popup/constants/connector-constants"; +import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab"; import { ComposerSuggestionGroup, ComposerSuggestionGroupHeading, @@ -26,18 +40,20 @@ import { ComposerSuggestionSeparator, ComposerSuggestionSkeleton, } from "@/components/new-chat/composer-suggestion-popup"; +import { + type ComposerSuggestionNavigatorRef, + type ComposerSuggestionNode, + useComposerSuggestionNavigator, +} from "@/components/new-chat/use-composer-suggestion-navigator"; import { Spinner } from "@/components/ui/spinner"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { queries } from "@/zero/queries"; -export interface DocumentMentionPickerRef { - selectHighlighted: () => void; - moveUp: () => void; - moveDown: () => void; -} +export type DocumentMentionPickerRef = ComposerSuggestionNavigatorRef; interface DocumentMentionPickerProps { searchSpaceId: number; @@ -51,34 +67,86 @@ const PAGE_SIZE = 20; const MIN_SEARCH_LENGTH = 2; const DEBOUNCE_MS = 100; -/** - * Custom debounce hook that delays value updates until user input stabilizes. - * Preferred over throttling for search inputs as it reduces API request frequency - * and prevents race conditions from stale responses overtaking recent ones. - */ +type BrowseView = + | { kind: "root" } + | { kind: "surfsense-docs" } + | { kind: "files-folders" } + | { kind: "connectors" } + | { kind: "connector-type"; connectorType: string; title: string }; + +type ResourceNodeValue = + | { kind: "view"; view: BrowseView } + | { kind: "mention"; mention: MentionedDocumentInfo }; + function useDebounced(value: T, delay = DEBOUNCE_MS) { const [debounced, setDebounced] = useState(value); const timeoutRef = useRef | undefined>(undefined); useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - setDebounced(value); - }, delay); - + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setDebounced(value), delay); return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, [value, delay]); return debounced; } +function titleForConnectorType(connectorType: string) { + const configured = + OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) || + COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType); + return ( + configured?.title || + connectorType + .replace(/_/g, " ") + .replace(/connector/gi, "") + .trim() + ); +} + +function makeDocMention(doc: Pick): MentionedDocumentInfo { + return { + id: doc.id, + title: doc.title, + document_type: doc.document_type, + kind: "doc", + }; +} + +function makeFolderMention(folder: { id: number; title: string }): MentionedDocumentInfo { + return { + id: folder.id, + title: folder.title, + document_type: FOLDER_MENTION_DOCUMENT_TYPE, + kind: "folder", + }; +} + +function makeConnectorMention(connector: SearchSourceConnector): MentionedDocumentInfo { + const accountName = getConnectorDisplayName(connector.name); + const connectorTitle = titleForConnectorType(connector.connector_type); + return { + id: connector.id, + title: `${connectorTitle}: ${accountName}`, + document_type: connector.connector_type, + kind: "connector", + connector_type: connector.connector_type, + account_name: accountName, + }; +} + +function mentionMatchesSearch(mention: MentionedDocumentInfo, searchLower: string) { + return [ + mention.title, + mention.document_type, + mention.kind, + mention.kind === "connector" ? mention.connector_type : "", + mention.kind === "connector" ? mention.account_name : "", + ].some((value) => value.toLowerCase().includes(searchLower)); +} + export const DocumentMentionPicker = forwardRef< DocumentMentionPickerRef, DocumentMentionPickerProps @@ -86,18 +154,14 @@ export const DocumentMentionPicker = forwardRef< { searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" }, ref ) { - // Debounced search value to minimize API calls and prevent race conditions const search = externalSearch; const debouncedSearch = useDebounced(search, DEBOUNCE_MS); - // Deferred snapshot of debouncedSearch — client-side filtering uses this so it - // is treated as a non-urgent update, keeping the input responsive. const deferredSearch = useDeferredValue(debouncedSearch); - const [highlightedIndex, setHighlightedIndex] = useState(0); - const itemRefs = useRef>(new Map()); - const scrollContainerRef = useRef(null); - const shouldScrollRef = useRef(false); // Keyboard navigation scroll flag + const hasSearch = debouncedSearch.trim().length > 0; + const isSearchValid = debouncedSearch.trim().length >= MIN_SEARCH_LENGTH; + const isSingleCharSearch = debouncedSearch.trim().length === 1; + const [view, setView] = useState({ kind: "root" }); - // Pagination state for infinite scroll const [accumulatedDocuments, setAccumulatedDocuments] = useState< Pick[] >([]); @@ -105,32 +169,26 @@ export const DocumentMentionPicker = forwardRef< const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); - // Folders for this search space — pulled from Zero so the picker - // stays consistent with the documents sidebar (same source of - // truth, automatic updates on rename/delete). const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId })); + const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom); + const paginationScopeKey = useMemo( + () => `${searchSpaceId}:${debouncedSearch}`, + [searchSpaceId, debouncedSearch] + ); + const previousPaginationScopeKeyRef = useRef(null); - /** - * Search Strategy: - * - Single character (length === 1): Client-side filtering for instant results - * - Two or more characters (length >= 2): Server-side search with pg_trgm index - * This hybrid approach optimizes UX by providing immediate feedback for short queries - * while leveraging efficient database indexing for longer, more specific searches. - */ - const isSearchValid = debouncedSearch.trim().length >= MIN_SEARCH_LENGTH; - const shouldSearch = debouncedSearch.trim().length > 0; - const isSingleCharSearch = debouncedSearch.trim().length === 1; - - // Reset pagination state when search query or search space changes. - // Documents are not cleared to maintain visual continuity during fetches. - // biome-ignore lint/correctness/useExhaustiveDependencies: Intentional reset on search/space change + // Reset pagination state when the active search scope changes. useEffect(() => { + if (previousPaginationScopeKeyRef.current === paginationScopeKey) return; + previousPaginationScopeKeyRef.current = paginationScopeKey; setCurrentPage(0); setHasMore(false); - setHighlightedIndex(0); - }, [debouncedSearch, searchSpaceId]); + }, [paginationScopeKey]); + + useEffect(() => { + if (hasSearch) setView({ kind: "root" }); + }, [hasSearch]); - // Query parameters for lightweight title search endpoint const titleSearchParams = useMemo( () => ({ search_space_id: searchSpaceId, @@ -146,77 +204,59 @@ export const DocumentMentionPicker = forwardRef< page: 0, page_size: PAGE_SIZE, }; - if (isSearchValid) { - params.title = debouncedSearch.trim(); - } + if (isSearchValid) params.title = debouncedSearch.trim(); return params; }, [debouncedSearch, isSearchValid]); - /** - * TanStack Query for document title search. - * - Uses AbortSignal for automatic request cancellation on query key changes - * - placeholderData: keepPreviousData maintains UI stability during fetches - * - Only triggers server-side search when isSearchValid (2+ characters) - */ const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({ queryKey: ["document-titles", titleSearchParams], queryFn: ({ signal }) => documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal), staleTime: 60 * 1000, - enabled: !!searchSpaceId && currentPage === 0 && (!shouldSearch || isSearchValid), + enabled: !!searchSpaceId && currentPage === 0 && (!hasSearch || isSearchValid), placeholderData: keepPreviousData, }); - /** - * TanStack Query for SurfSense documentation. - * - Uses AbortSignal for automatic request cancellation - * - placeholderData: keepPreviousData prevents UI flicker during refetches - */ const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({ queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid], queryFn: ({ signal }) => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal), staleTime: 3 * 60 * 1000, - enabled: !shouldSearch || isSearchValid, + enabled: !hasSearch || isSearchValid, placeholderData: keepPreviousData, }); - // Post-fetch filter to eliminate false positives from backend fuzzy matching const filterBySearchTerm = useCallback( (docs: Pick[]) => { - if (!isSearchValid) return docs; // No filtering when not searching + if (!isSearchValid) return docs; const searchLower = debouncedSearch.trim().toLowerCase(); return docs.filter((doc) => doc.title.toLowerCase().includes(searchLower)); }, [debouncedSearch, isSearchValid] ); - // Combine and update document list when first page data arrives useEffect(() => { - if (currentPage === 0) { - const combinedDocs: Pick[] = []; + if (currentPage !== 0) return; + const combinedDocs: Pick[] = []; - // SurfSense docs displayed first in the list - 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); - } - - setAccumulatedDocuments(filterBySearchTerm(combinedDocs)); } + + if (titleSearchResults?.items) { + combinedDocs.push(...titleSearchResults.items); + setHasMore(titleSearchResults.has_more); + } + + setAccumulatedDocuments(filterBySearchTerm(combinedDocs)); }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]); - // Load next page for infinite scroll pagination const loadNextPage = useCallback(async () => { if (isLoadingMore || !hasMore) return; @@ -230,9 +270,9 @@ export const DocumentMentionPicker = forwardRef< page_size: PAGE_SIZE, ...(isSearchValid ? { title: debouncedSearch.trim() } : {}), }; - const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles( - { queryParams } - ); + const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles({ + queryParams, + }); setAccumulatedDocuments((prev) => [...prev, ...response.items]); setHasMore(response.has_more); @@ -244,41 +284,12 @@ export const DocumentMentionPicker = forwardRef< } }, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId, isSearchValid]); - // Trigger pagination when user scrolls near the bottom (50px threshold) - const handleScroll = useCallback( - (e: React.UIEvent) => { - const target = e.currentTarget; - const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - - if (scrollBottom < 50 && hasMore && !isLoadingMore) { - loadNextPage(); - } - }, - [hasMore, isLoadingMore, loadNextPage] - ); - - /** - * Client-side filtering for single character searches. - * Filters cached documents locally for instant feedback without additional API calls. - * Server-side search is reserved for 2+ character queries to leverage database indexing. - * Uses deferredSearch (a deferred snapshot of debouncedSearch) so this memo is treated - * as non-urgent — React can interrupt it to keep the input responsive. - */ - const clientFilteredDocs = useMemo(() => { - if (!isSingleCharSearch) return null; + const actualDocuments = useMemo(() => { + if (!isSingleCharSearch) return accumulatedDocuments; const searchLower = deferredSearch.trim().toLowerCase(); return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower)); - }, [isSingleCharSearch, deferredSearch, accumulatedDocuments]); + }, [accumulatedDocuments, deferredSearch, isSingleCharSearch]); - // Select data source based on search length: client-filtered for single char, server results for 2+ - const actualDocuments = isSingleCharSearch ? (clientFilteredDocs ?? []) : accumulatedDocuments; - // Only show loading spinner on initial load (no documents yet), not during subsequent searches - const actualLoading = - (isTitleSearchLoading || isSurfsenseDocsLoading) && - currentPage === 0 && - !isSingleCharSearch && - accumulatedDocuments.length === 0; - // Partition documents by type for grouped UI rendering const surfsenseDocsList = useMemo( () => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"), [actualDocuments] @@ -287,47 +298,25 @@ export const DocumentMentionPicker = forwardRef< () => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"), [actualDocuments] ); - - // Folder mention candidates filtered by the current search term. - // Single-char and server-search both use the same client filter - // — folder counts in a workspace are tiny compared to docs, so we - // don't need a paged endpoint. Empty search shows all folders. - const folderMentions: MentionedDocumentInfo[] = useMemo(() => { - const all = (zeroFolders ?? []).map((f) => ({ - id: f.id, - title: f.name, - document_type: FOLDER_MENTION_DOCUMENT_TYPE, - kind: "folder" as const, - })); - if (!shouldSearch) return all; + const folderMentions = useMemo(() => { + const all = (zeroFolders ?? []).map((f) => makeFolderMention({ id: f.id, title: f.name })); + if (!hasSearch) return all; const needle = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase(); if (!needle) return all; return all.filter((f) => f.title.toLowerCase().includes(needle)); - }, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, shouldSearch]); + }, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, hasSearch]); + + const connectorMentions = useMemo( + () => connectors.filter((c) => c.is_active).map(makeConnectorMention), + [connectors] + ); - // Doc-shape entries reuse their ``document_type`` discriminator; - // folder entries lift the existing kind-aware key so the same - // matchers used by the chip atom apply unchanged. const selectedKeys = useMemo( () => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))), [initialSelectedDocuments] ); - // Combined navigation order: SurfSense docs -> User docs -> Folders. - // Mirrors the on-screen ordering so keyboard arrows match what the - // user sees. - const selectableMentions = useMemo(() => { - const docs: MentionedDocumentInfo[] = actualDocuments.map((doc) => ({ - id: doc.id, - title: doc.title, - document_type: doc.document_type, - kind: "doc" as const, - })); - const ordered = [...docs, ...folderMentions]; - return ordered.filter((m) => !selectedKeys.has(getMentionDocKey(m))); - }, [actualDocuments, folderMentions, selectedKeys]); - - const handleSelectMention = useCallback( + const selectMention = useCallback( (mention: MentionedDocumentInfo) => { onSelectionChange([...initialSelectedDocuments, mention]); onDone(); @@ -335,258 +324,303 @@ export const DocumentMentionPicker = forwardRef< [initialSelectedDocuments, onSelectionChange, onDone] ); - // Auto-scroll highlighted item into view (keyboard navigation only, not mouse hover) - useEffect(() => { - if (!shouldScrollRef.current) { - return; - } - shouldScrollRef.current = false; - - const rafId = requestAnimationFrame(() => { - const item = itemRefs.current.get(highlightedIndex); - const container = scrollContainerRef.current; - - if (item && container) { - const itemRect = item.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const padding = 8; - const isAboveViewport = itemRect.top < containerRect.top + padding; - const isBelowViewport = itemRect.bottom > containerRect.bottom - padding; - - if (isAboveViewport || isBelowViewport) { - const itemOffsetTop = item.offsetTop; - const containerHeight = container.clientHeight; - const itemHeight = item.offsetHeight; - const targetScrollTop = itemOffsetTop - containerHeight / 2 + itemHeight / 2; - const maxScrollTop = container.scrollHeight - containerHeight; - const clampedScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop)); - - container.scrollTo({ - top: clampedScrollTop, - behavior: "smooth", - }); - } - } - }); - - return () => cancelAnimationFrame(rafId); - }, [highlightedIndex]); - - // Reset highlight position when search query changes - const prevSearchRef = useRef(search); - if (prevSearchRef.current !== search) { - prevSearchRef.current = search; - if (highlightedIndex !== 0) { - setHighlightedIndex(0); - } - } - - // Expose navigation and selection methods to parent component via ref - useImperativeHandle( - ref, - () => ({ - selectHighlighted: () => { - if (selectableMentions[highlightedIndex]) { - handleSelectMention(selectableMentions[highlightedIndex]); - } + const rootNodes = useMemo[]>( + () => [ + { + id: "surfsense-docs", + label: "SurfSense Docs", + subtitle: "Browse product documentation", + icon: , + type: "branch", + value: { kind: "view", view: { kind: "surfsense-docs" } }, }, - moveUp: () => { - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1)); + { + id: "files-folders", + label: "Files & Folders", + subtitle: "Browse your knowledge base", + icon: , + type: "branch", + value: { kind: "view", view: { kind: "files-folders" } }, }, - moveDown: () => { - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0)); + { + id: "connectors", + label: "Connectors", + subtitle: connectors.length + ? "Choose the exact account for tool use" + : "No connected accounts yet", + icon: , + type: "branch", + disabled: connectors.length === 0, + value: { kind: "view", view: { kind: "connectors" } }, }, - }), - [selectableMentions, highlightedIndex, handleSelectMention] + ], + [connectors.length] ); - // Keyboard navigation handler for arrow keys, Enter, and Escape - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (selectableMentions.length === 0) return; + const searchNodes = useMemo[]>(() => { + const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase(); + const docNodes = actualDocuments.map((doc) => { + const mention = makeDocMention(doc); + return { + id: getMentionDocKey(mention), + label: doc.title, + icon: getConnectorIcon(doc.document_type, "size-4"), + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + }; + }); + const folderNodes = folderMentions.map((mention) => ({ + id: getMentionDocKey(mention), + label: mention.title, + subtitle: "Folder", + icon: , + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + })); + const connectorNodes = connectorMentions + .filter((mention) => !searchLower || mentionMatchesSearch(mention, searchLower)) + .map((mention) => ({ + id: getMentionDocKey(mention), + label: mention.title, + subtitle: "Connector account", + icon: getConnectorIcon(mention.document_type, "size-4") ?? , + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + })); - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0)); - break; - case "ArrowUp": - e.preventDefault(); - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1)); - break; - case "Enter": - e.preventDefault(); - if (selectableMentions[highlightedIndex]) { - handleSelectMention(selectableMentions[highlightedIndex]); - } - break; - case "Escape": - e.preventDefault(); - onDone(); - break; + return [...docNodes, ...folderNodes, ...connectorNodes]; + }, [ + actualDocuments, + connectorMentions, + debouncedSearch, + deferredSearch, + folderMentions, + isSingleCharSearch, + selectedKeys, + ]); + + const connectorTypeEntries = useMemo(() => { + const byType = new Map(); + for (const connector of connectors.filter((c) => c.is_active)) { + const list = byType.get(connector.connector_type) ?? []; + list.push(connector); + byType.set(connector.connector_type, list); + } + return Array.from(byType.entries()).sort(([a], [b]) => + titleForConnectorType(a).localeCompare(titleForConnectorType(b)) + ); + }, [connectors]); + + const browseNodes = useMemo[]>(() => { + if (view.kind === "root") return rootNodes; + if (view.kind === "surfsense-docs") { + return surfsenseDocsList.map((doc) => { + const mention = makeDocMention(doc); + return { + id: getMentionDocKey(mention), + label: doc.title, + icon: getConnectorIcon(doc.document_type, "size-4"), + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + }; + }); + } + if (view.kind === "files-folders") { + const folders = folderMentions.map((mention) => ({ + id: getMentionDocKey(mention), + label: mention.title, + subtitle: "Folder", + icon: , + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + })); + const docs = userDocsList.map((doc) => { + const mention = makeDocMention(doc); + return { + id: getMentionDocKey(mention), + label: doc.title, + icon: getConnectorIcon(doc.document_type, "size-4"), + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + }; + }); + return [...folders, ...docs]; + } + if (view.kind === "connectors") { + return connectorTypeEntries.map(([connectorType, typeConnectors]) => ({ + id: `connector-type:${connectorType}`, + label: titleForConnectorType(connectorType), + subtitle: `${typeConnectors.length} ${typeConnectors.length === 1 ? "account" : "accounts"}`, + icon: getConnectorIcon(connectorType, "size-4") ?? , + type: "branch" as const, + value: { + kind: "view" as const, + view: { + kind: "connector-type" as const, + connectorType, + title: titleForConnectorType(connectorType), + }, + }, + })); + } + return connectors + .filter((connector) => connector.is_active && connector.connector_type === view.connectorType) + .map((connector) => { + const mention = makeConnectorMention(connector); + return { + id: getMentionDocKey(mention), + label: getConnectorDisplayName(connector.name), + subtitle: `${view.title} account`, + icon: getConnectorIcon(connector.connector_type, "size-4") ?? , + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + }; + }); + }, [ + connectors, + connectorTypeEntries, + folderMentions, + rootNodes, + selectedKeys, + surfsenseDocsList, + userDocsList, + view, + ]); + + const visibleNodes = hasSearch ? searchNodes : browseNodes; + const handleNodeSelect = useCallback( + (node: ComposerSuggestionNode) => { + const value = node.value; + if (!value) return; + if (value.kind === "view") { + setView(value.view); + return; + } + selectMention(value.mention); + }, + [selectMention] + ); + const handleBack = useCallback(() => { + if (hasSearch || view.kind === "root") return false; + if (view.kind === "connector-type") { + setView({ kind: "connectors" }); + return true; + } + setView({ kind: "root" }); + return true; + }, [hasSearch, view]); + + const navigator = useComposerSuggestionNavigator({ + nodes: visibleNodes, + onSelect: handleNodeSelect, + onBack: handleBack, + ref, + }); + + const handleScroll = useCallback( + (e: React.UIEvent) => { + if (view.kind === "connectors" || view.kind === "connector-type") return; + const target = e.currentTarget; + const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + + if (scrollBottom < 50 && hasMore && !isLoadingMore) { + loadNextPage(); } }, - [selectableMentions, highlightedIndex, handleSelectMention, onDone] + [hasMore, isLoadingMore, loadNextPage, view.kind] ); + const actualLoading = + (isTitleSearchLoading || isSurfsenseDocsLoading || isConnectorsLoading) && + !isSingleCharSearch && + visibleNodes.length === 0 && + (view.kind === "root" || hasSearch); + + const title = + hasSearch || view.kind === "root" + ? null + : view.kind === "surfsense-docs" + ? "SurfSense Docs" + : view.kind === "files-folders" + ? "Files & Folders" + : view.kind === "connectors" + ? "Connectors" + : view.title; + return ( {actualLoading ? ( - ) : actualDocuments.length > 0 || folderMentions.length > 0 ? ( + ) : ( - {/* SurfSense Documentation */} - {surfsenseDocsList.length > 0 && ( + {title ? ( <> - SurfSense Docs - {surfsenseDocsList.map((doc) => { - const mention: MentionedDocumentInfo = { - id: doc.id, - title: doc.title, - document_type: doc.document_type, - kind: "doc", - }; - const docKey = getMentionDocKey(mention); - const isAlreadySelected = selectedKeys.has(docKey); - const selectableIndex = selectableMentions.findIndex( - (m) => getMentionDocKey(m) === docKey - ); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; - - return ( - { - if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el); - else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex); - }} - icon={getConnectorIcon(doc.document_type)} - selected={isHighlighted} - disabled={isAlreadySelected} - onClick={() => !isAlreadySelected && handleSelectMention(mention)} - onMouseEnter={() => { - if (!isAlreadySelected && selectableIndex >= 0) { - setHighlightedIndex(selectableIndex); - } - }} - > - - {doc.title} - - - ); - })} + } + muted + onClick={handleBack} + > + {title} + + + ) : null} + + {visibleNodes.length > 0 ? ( + <> + {hasSearch ? ( + Suggested Context + ) : null} + {visibleNodes.map((node, index) => ( + !node.disabled && handleNodeSelect(node)} + onMouseEnter={() => navigator.setHighlightedIndex(index)} + > + + + {node.label} + + {node.subtitle ? ( + + {node.subtitle} + + ) : null} + + {node.type === "branch" ? ( + + ) : null} + + ))} + + ) : ( + + {hasSearch ? "No matching context" : "No items available"} + )} - {/* User Documents */} - {userDocsList.length > 0 && ( - <> - {surfsenseDocsList.length > 0 && } - Your Documents - {userDocsList.map((doc) => { - const mention: MentionedDocumentInfo = { - id: doc.id, - title: doc.title, - document_type: doc.document_type, - kind: "doc", - }; - const docKey = getMentionDocKey(mention); - const isAlreadySelected = selectedKeys.has(docKey); - const selectableIndex = selectableMentions.findIndex( - (m) => getMentionDocKey(m) === docKey - ); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; - - return ( - { - if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el); - else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex); - }} - icon={getConnectorIcon(doc.document_type)} - selected={isHighlighted} - disabled={isAlreadySelected} - onClick={() => !isAlreadySelected && handleSelectMention(mention)} - onMouseEnter={() => { - if (!isAlreadySelected && selectableIndex >= 0) { - setHighlightedIndex(selectableIndex); - } - }} - > - - {doc.title} - - - ); - })} - - )} - - {/* Folders — single source of truth is Zero (same store - that powers the documents sidebar). Selecting a - folder inserts a folder chip whose path the agent - can walk with ``ls`` / ``find_documents``. */} - {folderMentions.length > 0 && ( - <> - {(surfsenseDocsList.length > 0 || userDocsList.length > 0) && ( - - )} - Folders - {folderMentions.map((folder) => { - const folderKey = getMentionDocKey(folder); - const isAlreadySelected = selectedKeys.has(folderKey); - const selectableIndex = selectableMentions.findIndex( - (m) => getMentionDocKey(m) === folderKey - ); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; - - return ( - { - if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el); - else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex); - }} - icon={} - selected={isHighlighted} - disabled={isAlreadySelected} - onClick={() => !isAlreadySelected && handleSelectMention(folder)} - onMouseEnter={() => { - if (!isAlreadySelected && selectableIndex >= 0) { - setHighlightedIndex(selectableIndex); - } - }} - > - - {folder.title} - - - ); - })} - - )} - - {/* Pagination loading indicator */} {isLoadingMore && (
)}
- ) : ( - No matching documents )}
); diff --git a/surfsense_web/components/new-chat/use-composer-suggestion-navigator.ts b/surfsense_web/components/new-chat/use-composer-suggestion-navigator.ts new file mode 100644 index 000000000..da4dc60c3 --- /dev/null +++ b/surfsense_web/components/new-chat/use-composer-suggestion-navigator.ts @@ -0,0 +1,120 @@ +"use client"; + +import type * as React from "react"; +import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; + +export type ComposerSuggestionNode = { + id: string; + label: string; + subtitle?: string; + icon?: React.ReactNode; + keywords?: string[]; + type: "branch" | "item" | "action"; + value?: TValue; + disabled?: boolean; +}; + +export type ComposerSuggestionNavigatorRef = { + selectHighlighted: () => void; + moveUp: () => void; + moveDown: () => void; + goBack: () => boolean; +}; + +export type ComposerSuggestionNavigatorOptions = { + nodes: ComposerSuggestionNode[]; + onSelect: (node: ComposerSuggestionNode) => void; + onBack?: () => boolean; + ref?: React.Ref; +}; + +export function useComposerSuggestionNavigator({ + nodes, + onSelect, + onBack, + ref, +}: ComposerSuggestionNavigatorOptions) { + const [highlightedIndex, setHighlightedIndex] = useState(0); + const itemRefs = useRef>(new Map()); + const scrollContainerRef = useRef(null); + const shouldScrollRef = useRef(false); + const nodesKey = useMemo(() => nodes.map((node) => node.id).join("\u0000"), [nodes]); + const previousNodesKeyRef = useRef(null); + + // Reset keyboard focus when the caller swaps the visible node set. + useEffect(() => { + if (previousNodesKeyRef.current === nodesKey) return; + previousNodesKeyRef.current = nodesKey; + setHighlightedIndex(0); + itemRefs.current.clear(); + }, [nodesKey]); + + useEffect(() => { + if (!shouldScrollRef.current) return; + shouldScrollRef.current = false; + + const rafId = requestAnimationFrame(() => { + const item = itemRefs.current.get(highlightedIndex); + const container = scrollContainerRef.current; + if (!item || !container) return; + + const itemRect = item.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { + item.scrollIntoView({ block: "nearest" }); + } + }); + + return () => cancelAnimationFrame(rafId); + }, [highlightedIndex]); + + const moveUp = useCallback(() => { + if (nodes.length === 0) return; + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : nodes.length - 1)); + }, [nodes.length]); + + const moveDown = useCallback(() => { + if (nodes.length === 0) return; + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev < nodes.length - 1 ? prev + 1 : 0)); + }, [nodes.length]); + + const selectHighlighted = useCallback(() => { + const node = nodes[highlightedIndex]; + if (!node || node.disabled) return; + onSelect(node); + }, [highlightedIndex, nodes, onSelect]); + + const goBack = useCallback(() => onBack?.() ?? false, [onBack]); + + useImperativeHandle( + ref, + () => ({ + selectHighlighted, + moveUp, + moveDown, + goBack, + }), + [goBack, moveDown, moveUp, selectHighlighted] + ); + + const getItemRef = useCallback( + (index: number) => (el: HTMLButtonElement | null) => { + if (el) itemRefs.current.set(index, el); + else itemRefs.current.delete(index); + }, + [] + ); + + return { + highlightedIndex, + setHighlightedIndex, + scrollContainerRef, + getItemRef, + moveUp, + moveDown, + selectHighlighted, + goBack, + }; +} diff --git a/surfsense_web/lib/chat/mention-doc-key.ts b/surfsense_web/lib/chat/mention-doc-key.ts index 5c0bd6254..114faace5 100644 --- a/surfsense_web/lib/chat/mention-doc-key.ts +++ b/surfsense_web/lib/chat/mention-doc-key.ts @@ -1,7 +1,7 @@ type MentionKeyInput = { id: number; document_type?: string | null; - kind?: "doc" | "folder"; + kind?: "doc" | "folder" | "connector"; }; /** From 2d134439ecae7a2f1492e7880429617edff5f222 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 21:52:04 +0530 Subject: [PATCH 06/10] feat(web): enhance mention handling to support connectors and improve document key management --- surfsense_backend/app/schemas/new_chat.py | 19 ++--- .../app/tasks/chat/persistence.py | 19 +++-- .../new-chat/[[...chat_id]]/page.tsx | 15 ++-- .../atoms/chat/mentioned-documents.atom.ts | 34 +++----- .../assistant-ui/inline-mention-editor.tsx | 68 +++++++++++----- .../components/assistant-ui/thread.tsx | 31 ++++---- .../components/assistant-ui/user-message.tsx | 2 +- .../components/documents/FolderTreeView.tsx | 1 - .../new-chat/document-mention-picker.tsx | 44 ++++++----- surfsense_web/lib/chat/mention-doc-key.ts | 12 +-- surfsense_web/lib/connector-telemetry.ts | 79 +++++++------------ 11 files changed, 160 insertions(+), 164 deletions(-) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index c721f495e..8b49413c6 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -203,13 +203,11 @@ class NewChatUserImagePart(BaseModel): class MentionedDocumentInfo(BaseModel): """Display metadata for a single ``@``-mention chip. - Carries either a knowledge-base document or a knowledge-base folder - (discriminated by ``kind``). The full triple - ``{id, title, document_type}`` is forwarded by the frontend mention - chip so the server can embed it in the persisted user message - ``ContentPart[]`` (single ``mentioned-documents`` part). The - history loader then renders the chips on reload without an extra - fetch — mirrors the pre-refactor frontend ``persistUserTurn`` shape. + Carries a knowledge-base document, knowledge-base folder, or + connected account (discriminated by ``kind``). Each kind uses its + real identity fields: docs carry ``document_type``, folders carry + only their folder id/title, and connectors carry ``connector_type`` + plus account metadata. ``kind`` defaults to ``"doc"`` so legacy clients and persisted rows that predate folder mentions deserialise unchanged. @@ -217,17 +215,14 @@ class MentionedDocumentInfo(BaseModel): id: int title: str = Field(..., min_length=1, max_length=500) - document_type: str = Field(..., min_length=1, max_length=100) + document_type: str | None = Field(default=None, min_length=1, max_length=100) kind: Literal["doc", "folder", "connector"] = Field( default="doc", description=( "Discriminator for the chip's referent: ``doc`` is a " "knowledge-base ``Document`` row, ``folder`` is a " "knowledge-base ``Folder`` row, and ``connector`` is a " - "concrete connected account. Folders carry the sentinel " - "``document_type='FOLDER'`` to keep the frontend dedup key " - "``(kind:document_type:id)`` from colliding doc and folder " - "ids that happen to share an integer value." + "concrete connected account." ), ) connector_type: str | None = Field(default=None, max_length=100) diff --git a/surfsense_backend/app/tasks/chat/persistence.py b/surfsense_backend/app/tasks/chat/persistence.py index 07266cf69..9d100c13c 100644 --- a/surfsense_backend/app/tasks/chat/persistence.py +++ b/surfsense_backend/app/tasks/chat/persistence.py @@ -109,7 +109,7 @@ def _build_user_content( [{"type": "text", "text": "..."}, {"type": "image", "image": "data:..."}, {"type": "mentioned-documents", "documents": [{"id": int, - "title": str, "document_type": str, "kind": "doc" | "folder"}, + "title": str, "kind": "doc" | "folder" | "connector", ...}, ...]}] The companion reader is @@ -117,8 +117,8 @@ def _build_user_content( which expects exactly this shape — keep them in sync. ``mentioned_documents``: optional list of mention chip dicts. Each - dict may include a ``kind`` discriminator (``"doc"`` or ``"folder"``) - so the persisted ContentPart round-trips folder chips on reload. + dict may include a ``kind`` discriminator so the persisted + ContentPart round-trips folder and connector chips on reload. When ``kind`` is missing we default to ``"doc"`` so legacy clients that haven't migrated to the union schema still persist correctly. """ @@ -134,18 +134,23 @@ def _build_user_content( doc_id = doc.get("id") title = doc.get("title") document_type = doc.get("document_type") - if doc_id is None or title is None or document_type is None: - continue kind_raw = doc.get("kind", "doc") kind = kind_raw if kind_raw in ("doc", "folder", "connector") else "doc" + if doc_id is None or title is None: + continue + if kind == "doc" and document_type is None: + continue item = { "id": doc_id, "title": str(title), - "document_type": str(document_type), "kind": kind, } + if document_type is not None: + item["document_type"] = str(document_type) if kind == "connector": - connector_type = doc.get("connector_type") or document_type + connector_type = doc.get("connector_type") + if connector_type is None: + continue account_name = doc.get("account_name") or title item["connector_type"] = str(connector_type) item["account_name"] = str(account_name) 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 8d1f5da46..6cd95a79c 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 @@ -73,6 +73,7 @@ import { convertToThreadMessage, reconcileInterruptedAssistantMessages, } from "@/lib/chat/message-utils"; +import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { isPodcastGenerating, looksLikePodcastRequest, @@ -206,7 +207,7 @@ function pairBundleToolCallIds( const MentionedDocumentInfoSchema = z.object({ id: z.number(), title: z.string(), - document_type: z.string(), + document_type: z.string().optional(), kind: z .union([z.literal("doc"), z.literal("folder"), z.literal("connector")]) .optional() @@ -234,9 +235,8 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { return { id: doc.id, title: doc.title, - document_type: doc.document_type, kind: "connector", - connector_type: doc.connector_type ?? doc.document_type, + connector_type: doc.connector_type ?? doc.document_type ?? "UNKNOWN", account_name: doc.account_name ?? doc.title, }; } @@ -244,14 +244,13 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { return { id: doc.id, title: doc.title, - document_type: "FOLDER", kind: "folder", }; } return { id: doc.id, title: doc.title, - document_type: doc.document_type, + document_type: doc.document_type ?? "UNKNOWN", kind: "doc", }; }); @@ -957,15 +956,13 @@ export default function NewChatPage() { }); // Collect unique mention chips for display & persistence. - // Dedup key is ``kind:document_type:id`` so a folder and a - // doc with the same integer id never collapse into one - // entry. The ``kind`` field is forwarded to the backend + // The ``kind`` field is forwarded to the backend // so the persisted ``mentioned-documents`` content part // can render the correct chip type on reload. const allMentionedDocs: MentionedDocumentInfo[] = []; const seenDocKeys = new Set(); for (const doc of mentionedDocuments) { - const key = `${doc.kind}:${doc.document_type}:${doc.id}`; + const key = getMentionDocKey(doc); if (seenDocKeys.has(key)) continue; seenDocKeys.add(key); allMentionedDocs.push(doc); diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 9efd2b7fe..25d1e397a 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -3,13 +3,6 @@ import { atom } from "jotai"; import type { Document } from "@/contracts/types/document.types"; -/** - * Sentinel ``document_type`` used for folder mention chips so the - * dedup key (`kind:document_type:id`) never collides a document with a - * folder that happens to share an integer id. - */ -export const FOLDER_MENTION_DOCUMENT_TYPE = "FOLDER"; - /** * Display metadata for a single ``@``-mention chip. * @@ -27,13 +20,11 @@ export type MentionedDocumentInfo = | { id: number; title: string; - document_type: typeof FOLDER_MENTION_DOCUMENT_TYPE; kind: "folder"; } | { id: number; title: string; - document_type: string; kind: "connector"; connector_type: string; account_name: string; @@ -51,8 +42,7 @@ type LegacyDocMention = Pick; * Normalize an arbitrary chip-like input into the discriminated * ``MentionedDocumentInfo`` shape. Existing call sites that only have * ``{id, title, document_type}`` flow through here so they don't have - * to thread ``kind`` everywhere — the helper defaults to ``"doc"`` and - * rewrites the document type for folders. + * to thread ``kind`` everywhere — the helper defaults to ``"doc"``. */ export function toMentionedDocumentInfo( input: LegacyDocMention | MentionedDocumentInfo @@ -78,31 +68,32 @@ export function makeFolderMention(input: { id: number; name: string }): Mentione return { id: input.id, title: input.name, - document_type: FOLDER_MENTION_DOCUMENT_TYPE, kind: "folder", }; } /** - * Atom to store the full mention objects (documents + folders) attached - * via @-mention chips in the current chat composer. Persists across - * component remounts. + * Atom to store the full context objects attached via @-mention chips in + * the current chat composer. Persists across component remounts. */ export const mentionedDocumentsAtom = atom([]); /** * Derived read-only atom that maps deduplicated mention chips into - * backend payload fields. Doc chips split by ``document_type`` exactly - * like before; folder chips are projected into a separate - * ``folder_ids`` bucket so the route can forward - * ``mentioned_folder_ids`` to the agent without the priority middleware - * conflating them with hybrid-search ids. + * backend payload fields. Each mention kind maps to its own explicit + * payload bucket so non-document context never has to masquerade as a + * document type. */ export const mentionedDocumentIdsAtom = atom((get) => { const allMentions = get(mentionedDocumentsAtom); const seen = new Set(); const deduped = allMentions.filter((m) => { - const key = `${m.kind}:${m.document_type}:${m.id}`; + const key = + m.kind === "doc" + ? `doc:${m.document_type}:${m.id}` + : m.kind === "connector" + ? `connector:${m.connector_type}:${m.id}` + : `folder:${m.id}`; if (seen.has(key)) return false; seen.add(key); return true; @@ -120,7 +111,6 @@ export const mentionedDocumentIdsAtom = atom((get) => { connectors: connectors.map((c) => ({ id: c.id, title: c.title, - document_type: c.document_type, kind: c.kind, connector_type: c.connector_type, account_name: c.account_name, diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index b93ea253d..c0d9d9212 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -20,7 +20,6 @@ import { useMemo, useRef, } from "react"; -import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom"; import { Button } from "@/components/ui/button"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; @@ -40,8 +39,6 @@ export interface MentionedDocument { /** * Input shape for inserting a chip. ``kind`` defaults to ``"doc"``. - * Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE`` - * so the dedup key never collides with a doc chip sharing the same id. */ export type MentionChipInput = { id: number; @@ -78,7 +75,12 @@ export interface InlineMentionEditorRef { doc: Pick, options?: { removeTriggerText?: boolean } ) => void; - removeDocumentChip: (docId: number, docType?: string) => void; + removeDocumentChip: ( + docId: number, + docType?: string, + kind?: MentionKind, + connectorType?: string + ) => void; setDocumentChipStatus: ( docId: number, docType: string | undefined, @@ -95,7 +97,7 @@ interface InlineMentionEditorProps { onActionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; - onDocumentRemove?: (docId: number, docType?: string) => void; + onDocumentRemove?: (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; disabled?: boolean; className?: string; @@ -135,7 +137,12 @@ const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }]; * the X button and Backspace go through the same call site. */ type MentionEditorContextValue = { - removeChip: (docId: number, docType: string | undefined) => void; + removeChip: ( + docId: number, + docType: string | undefined, + kind: MentionKind | undefined, + connectorType: string | undefined + ) => void; }; const MentionEditorContext = createContext(null); @@ -181,7 +188,12 @@ const MentionElement: FC> = ({ onMouseDown={(e) => e.preventDefault()} onClick={(e) => { e.stopPropagation(); - ctx.removeChip(element.id, element.document_type); + ctx.removeChip( + element.id, + element.document_type, + element.kind, + element.connector_type + ); }} className="absolute inset-0 size-3 rounded-sm p-0 opacity-0 transition-opacity hover:bg-transparent hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-0 group-hover:opacity-100 [&_svg]:size-3" > @@ -456,18 +468,11 @@ export const InlineMentionEditor = forwardRef { + (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => { const match = (n: unknown) => { if (!n || typeof n !== "object" || !("type" in n)) return false; const node = n as MentionElementNode; if (node.type !== MENTION_TYPE) return false; if (node.id !== docId) return false; + if (kind) { + return ( + getMentionDocKey({ + id: node.id, + kind: node.kind ?? "doc", + document_type: node.document_type, + connector_type: node.connector_type, + }) === + getMentionDocKey({ + id: docId, + kind, + document_type: docType, + connector_type: connectorType, + }) + ); + } return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN"); }; @@ -554,9 +575,14 @@ export const InlineMentionEditor = forwardRef { - removeDocumentChip(docId, docType); - onDocumentRemove?.(docId, docType); + ( + docId: number, + docType: string | undefined, + kind: MentionKind | undefined, + connectorType: string | undefined + ) => { + removeDocumentChip(docId, docType, kind, connectorType); + onDocumentRemove?.(docId, docType, kind, connectorType); }, [onDocumentRemove, removeDocumentChip] ); @@ -679,7 +705,7 @@ export const InlineMentionEditor = forwardRef { } } return docs.map((d) => { - const documentType = d.document_type ?? "UNKNOWN"; if (d.kind === "connector") { return { id: d.id, title: d.title, - document_type: documentType, kind: "connector", - connector_type: d.connector_type ?? documentType, + connector_type: d.connector_type ?? "UNKNOWN", account_name: d.account_name ?? d.title, }; } @@ -559,17 +556,13 @@ const Composer: FC = () => { return { id: d.id, title: d.title, - document_type: FOLDER_MENTION_DOCUMENT_TYPE, kind: "folder", }; } return { id: d.id, title: d.title, - // Atom requires a string; ``"UNKNOWN"`` matches the - // sentinel ``getMentionDocKey`` and the editor's - // match predicates use. - document_type: documentType, + document_type: d.document_type ?? "UNKNOWN", kind: "doc", }; }); @@ -760,13 +753,14 @@ const Composer: FC = () => { ]); const handleDocumentRemove = useCallback( - (docId: number, docType?: string) => { + (docId: number, docType?: string, kind?: "doc" | "folder" | "connector", connectorType?: string) => { setMentionedDocuments((prev) => { - if (!docType) { - // Fallback when chip type is unavailable. - return prev.filter((doc) => doc.id !== docId); - } - const removedKey = getMentionDocKey({ id: docId, document_type: docType }); + const removedKey = getMentionDocKey({ + id: docId, + document_type: docType, + kind, + connector_type: connectorType, + }); return prev.filter((doc) => getMentionDocKey(doc) !== removedKey); }); }, @@ -810,7 +804,12 @@ const Composer: FC = () => { for (const [key, doc] of prevDocsMap) { if (!nextDocsMap.has(key)) { - editor.removeDocumentChip(doc.id, doc.document_type); + editor.removeDocumentChip( + doc.id, + doc.kind === "doc" ? doc.document_type : undefined, + doc.kind, + doc.kind === "connector" ? doc.connector_type : undefined + ); } } diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 3e6dc829a..b30db5f69 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -104,7 +104,7 @@ const UserTextPart: FC = () => { const icon = isFolder ? ( ) : isConnector ? ( - getConnectorIcon(segment.doc.connector_type ?? segment.doc.document_type, "size-3.5") ?? ( + getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? ( ) ) : ( diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index fb1030028..7c076e99a 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -190,7 +190,6 @@ export function FolderTreeView({ for (const f of folders) { const folderMentionKey = getMentionDocKey({ id: f.id, - document_type: "FOLDER", kind: "folder", }); states[f.id] = mentionedDocKeys.has(folderMentionKey) ? "all" : "none"; diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index f8a84c51b..c424ae6c3 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -20,10 +20,7 @@ import { useState, } from "react"; import type * as React from "react"; -import { - FOLDER_MENTION_DOCUMENT_TYPE, - type MentionedDocumentInfo, -} from "@/atoms/chat/mentioned-documents.atom"; +import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom"; import { useAtomValue } from "jotai"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { @@ -78,6 +75,10 @@ type ResourceNodeValue = | { kind: "view"; view: BrowseView } | { kind: "mention"; mention: MentionedDocumentInfo }; +function isConnectorActive(connector: SearchSourceConnector) { + return connector.is_active !== false; +} + function useDebounced(value: T, delay = DEBOUNCE_MS) { const [debounced, setDebounced] = useState(value); const timeoutRef = useRef | undefined>(undefined); @@ -115,22 +116,24 @@ function makeDocMention(doc: Pick): }; } -function makeFolderMention(folder: { id: number; title: string }): MentionedDocumentInfo { +function makeFolderMention( + folder: { id: number; title: string } +): Extract { return { id: folder.id, title: folder.title, - document_type: FOLDER_MENTION_DOCUMENT_TYPE, kind: "folder", }; } -function makeConnectorMention(connector: SearchSourceConnector): MentionedDocumentInfo { +function makeConnectorMention( + connector: SearchSourceConnector +): Extract { const accountName = getConnectorDisplayName(connector.name); const connectorTitle = titleForConnectorType(connector.connector_type); return { id: connector.id, title: `${connectorTitle}: ${accountName}`, - document_type: connector.connector_type, kind: "connector", connector_type: connector.connector_type, account_name: accountName, @@ -140,8 +143,8 @@ function makeConnectorMention(connector: SearchSourceConnector): MentionedDocume function mentionMatchesSearch(mention: MentionedDocumentInfo, searchLower: string) { return [ mention.title, - mention.document_type, mention.kind, + mention.kind === "doc" ? mention.document_type : "", mention.kind === "connector" ? mention.connector_type : "", mention.kind === "connector" ? mention.account_name : "", ].some((value) => value.toLowerCase().includes(searchLower)); @@ -171,6 +174,7 @@ export const DocumentMentionPicker = forwardRef< const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId })); const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom); + const activeConnectors = useMemo(() => connectors.filter(isConnectorActive), [connectors]); const paginationScopeKey = useMemo( () => `${searchSpaceId}:${debouncedSearch}`, [searchSpaceId, debouncedSearch] @@ -307,8 +311,8 @@ export const DocumentMentionPicker = forwardRef< }, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, hasSearch]); const connectorMentions = useMemo( - () => connectors.filter((c) => c.is_active).map(makeConnectorMention), - [connectors] + () => activeConnectors.map(makeConnectorMention), + [activeConnectors] ); const selectedKeys = useMemo( @@ -345,16 +349,16 @@ export const DocumentMentionPicker = forwardRef< { id: "connectors", label: "Connectors", - subtitle: connectors.length + subtitle: activeConnectors.length ? "Choose the exact account for tool use" : "No connected accounts yet", icon: , type: "branch", - disabled: connectors.length === 0, + disabled: activeConnectors.length === 0, value: { kind: "view", view: { kind: "connectors" } }, }, ], - [connectors.length] + [activeConnectors.length] ); const searchNodes = useMemo[]>(() => { @@ -385,7 +389,7 @@ export const DocumentMentionPicker = forwardRef< id: getMentionDocKey(mention), label: mention.title, subtitle: "Connector account", - icon: getConnectorIcon(mention.document_type, "size-4") ?? , + icon: getConnectorIcon(mention.connector_type, "size-4") ?? , type: "item" as const, disabled: selectedKeys.has(getMentionDocKey(mention)), value: { kind: "mention" as const, mention }, @@ -404,7 +408,7 @@ export const DocumentMentionPicker = forwardRef< const connectorTypeEntries = useMemo(() => { const byType = new Map(); - for (const connector of connectors.filter((c) => c.is_active)) { + for (const connector of activeConnectors) { const list = byType.get(connector.connector_type) ?? []; list.push(connector); byType.set(connector.connector_type, list); @@ -412,7 +416,7 @@ export const DocumentMentionPicker = forwardRef< return Array.from(byType.entries()).sort(([a], [b]) => titleForConnectorType(a).localeCompare(titleForConnectorType(b)) ); - }, [connectors]); + }, [activeConnectors]); const browseNodes = useMemo[]>(() => { if (view.kind === "root") return rootNodes; @@ -469,8 +473,8 @@ export const DocumentMentionPicker = forwardRef< }, })); } - return connectors - .filter((connector) => connector.is_active && connector.connector_type === view.connectorType) + return activeConnectors + .filter((connector) => connector.connector_type === view.connectorType) .map((connector) => { const mention = makeConnectorMention(connector); return { @@ -484,7 +488,7 @@ export const DocumentMentionPicker = forwardRef< }; }); }, [ - connectors, + activeConnectors, connectorTypeEntries, folderMentions, rootNodes, diff --git a/surfsense_web/lib/chat/mention-doc-key.ts b/surfsense_web/lib/chat/mention-doc-key.ts index 114faace5..87676dbd6 100644 --- a/surfsense_web/lib/chat/mention-doc-key.ts +++ b/surfsense_web/lib/chat/mention-doc-key.ts @@ -1,18 +1,20 @@ type MentionKeyInput = { id: number; document_type?: string | null; + connector_type?: string | null; kind?: "doc" | "folder" | "connector"; }; /** * Build a stable dedup key for a mention chip. * - * The ``kind:document_type:id`` shape prevents a document and a folder - * with the same integer id from colliding in the chip array (folders - * use the ``FOLDER`` sentinel ``document_type``; the ``kind`` prefix - * is the belt-and-braces guard). + * Each mention kind keys off its real identity fields: + * docs by document type, folders by folder id, and connectors by + * connector type + account id. */ export function getMentionDocKey(doc: MentionKeyInput): string { const kind = doc.kind ?? "doc"; - return `${kind}:${doc.document_type ?? "UNKNOWN"}:${doc.id}`; + if (kind === "folder") return `folder:${doc.id}`; + if (kind === "connector") return `connector:${doc.connector_type ?? "UNKNOWN"}:${doc.id}`; + return `doc:${doc.document_type ?? "UNKNOWN"}:${doc.id}`; } diff --git a/surfsense_web/lib/connector-telemetry.ts b/surfsense_web/lib/connector-telemetry.ts index ef1b3de32..eeccea1e8 100644 --- a/surfsense_web/lib/connector-telemetry.ts +++ b/surfsense_web/lib/connector-telemetry.ts @@ -1,22 +1,17 @@ -import { EnumConnectorName } from "@/contracts/enums/connector"; -import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { - OAUTH_CONNECTORS, COMPOSIO_CONNECTORS, CRAWLERS, + OAUTH_CONNECTORS, OTHER_CONNECTORS, } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; // ============================================================================= // Connector Telemetry Types & Registry // ============================================================================= -export type ConnectorTelemetryGroup = - | "oauth" - | "composio" - | "crawler" - | "other" - | "unknown"; +export type ConnectorTelemetryGroup = "oauth" | "composio" | "crawler" | "other" | "unknown"; export interface ConnectorTelemetryMeta { connector_type: string; @@ -31,10 +26,11 @@ export interface ConnectorTelemetryMeta { * picked up here, so adding a new integration does NOT require touching * `lib/posthog/events.ts` or per-connector tracking code. */ -const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap< - string, - ConnectorTelemetryMeta -> = (() => { +let connectorTelemetryRegistry: ReadonlyMap | undefined; + +function getConnectorTelemetryRegistry(): ReadonlyMap { + if (connectorTelemetryRegistry) return connectorTelemetryRegistry; + const map = new Map(); for (const c of OAUTH_CONNECTORS) { @@ -70,18 +66,17 @@ const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap< }); } - return map; -})(); + connectorTelemetryRegistry = map; + return connectorTelemetryRegistry; +} /** * Returns telemetry metadata for a connector_type, or a minimal "unknown" * record so tracking never no-ops for connectors that exist in the backend * but were forgotten in the UI registry. */ -export function getConnectorTelemetryMeta( - connectorType: string, -): ConnectorTelemetryMeta { - const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); +export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta { + const hit = getConnectorTelemetryRegistry().get(connectorType); if (hit) return hit; return { @@ -101,34 +96,20 @@ export function getConnectorTelemetryMeta( * These are used for connectors that were NOT created via MCP OAuth. */ const LEGACY_REAUTH_ENDPOINTS: Partial> = { - [EnumConnectorName.LINEAR_CONNECTOR]: - "/api/v1/auth/linear/connector/reauth", - [EnumConnectorName.JIRA_CONNECTOR]: - "/api/v1/auth/jira/connector/reauth", - [EnumConnectorName.NOTION_CONNECTOR]: - "/api/v1/auth/notion/connector/reauth", - [EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: - "/api/v1/auth/google/drive/connector/reauth", - [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: - "/api/v1/auth/google/gmail/connector/reauth", - [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: - "/api/v1/auth/google/calendar/connector/reauth", - [EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: - "/api/v1/auth/composio/connector/reauth", - [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: - "/api/v1/auth/composio/connector/reauth", - [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: - "/api/v1/auth/composio/connector/reauth", - [EnumConnectorName.ONEDRIVE_CONNECTOR]: - "/api/v1/auth/onedrive/connector/reauth", - [EnumConnectorName.DROPBOX_CONNECTOR]: - "/api/v1/auth/dropbox/connector/reauth", - [EnumConnectorName.CONFLUENCE_CONNECTOR]: - "/api/v1/auth/confluence/connector/reauth", - [EnumConnectorName.TEAMS_CONNECTOR]: - "/api/v1/auth/teams/connector/reauth", - [EnumConnectorName.DISCORD_CONNECTOR]: - "/api/v1/auth/discord/connector/reauth", + [EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth", + [EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth", + [EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth", + [EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth", + [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth", + [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth", + [EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth", + [EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth", + [EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth", + [EnumConnectorName.TEAMS_CONNECTOR]: "/api/v1/auth/teams/connector/reauth", + [EnumConnectorName.DISCORD_CONNECTOR]: "/api/v1/auth/discord/connector/reauth", }; /** @@ -138,9 +119,7 @@ const LEGACY_REAUTH_ENDPOINTS: Partial> = { * the URL from the service key. Legacy OAuth connectors fall back to the * static ``LEGACY_REAUTH_ENDPOINTS`` map. */ -export function getReauthEndpoint( - connector: SearchSourceConnector, -): string | undefined { +export function getReauthEndpoint(connector: SearchSourceConnector): string | undefined { const mcpService = connector.config?.mcp_service as string | undefined; if (mcpService) { return `/api/v1/auth/mcp/${mcpService}/connector/reauth`; From 79f5e8f88c22afba5075dac7b5302bb06bb285c5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 22:40:22 +0530 Subject: [PATCH 07/10] feat(web): add connector display definitions and enhance composer suggestion components --- .../constants/connector-constants.ts | 15 ++- .../new-chat/composer-suggestion-popup.tsx | 43 +++++-- .../new-chat/document-mention-picker.tsx | 119 +++++++++--------- 3 files changed, 105 insertions(+), 72 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 01a911d70..dedb35465 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -1,5 +1,4 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; -import type { SearchSourceConnector } from "@/contracts/types/connector.types"; /** * Connectors that operate in real time (no background indexing). @@ -230,6 +229,20 @@ export const COMPOSIO_CONNECTORS = [ }, ] as const; +export const CONNECTOR_DISPLAY_DEFINITIONS = [ + ...OAUTH_CONNECTORS, + ...CRAWLERS, + ...OTHER_CONNECTORS, + ...COMPOSIO_CONNECTORS, +] as const; + +export function getConnectorTitle(connectorType: string): string { + return ( + CONNECTOR_DISPLAY_DEFINITIONS.find((connector) => connector.connectorType === connectorType) + ?.title ?? connectorType + ); +} + // Composio Toolkits (available integrations via Composio) export const COMPOSIO_TOOLKITS = [ { diff --git a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx index fd46d8ee7..d72cf1366 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-[280px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[320px]", + "w-[256px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[288px]", "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,12 +63,32 @@ function ComposerSuggestionGroupHeading({ }: React.HTMLAttributes) { return (
); } +function ComposerSuggestionHeader({ + className, + icon, + children, + ...props +}: React.HTMLAttributes & { icon?: React.ReactNode }) { + return ( +
+ {icon ? {icon} : null} + {children} +
+ ); +} + const ComposerSuggestionItem = React.forwardRef< HTMLButtonElement, Omit, "variant"> & { @@ -83,7 +103,7 @@ const ComposerSuggestionItem = React.forwardRef< variant="ghost" disabled={disabled} className={cn( - "h-auto w-full justify-start gap-2 rounded-md px-3 py-2 text-left text-sm font-normal transition-colors", + "h-auto w-full justify-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm 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", @@ -99,7 +119,7 @@ ComposerSuggestionItem.displayName = "ComposerSuggestionItem"; function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps) { return ( -
+
); @@ -111,10 +131,10 @@ function ComposerSuggestionMessage({ variant = "muted", }: React.HTMLAttributes & { variant?: "muted" | "destructive" }) { return ( -
+

-

+
+
{["a", "b", "c", "d", "e"].map((id, index) => (
= 3 && "hidden sm:flex" )} > @@ -156,6 +176,7 @@ export { ComposerSuggestionList, ComposerSuggestionGroup, ComposerSuggestionGroupHeading, + ComposerSuggestionHeader, ComposerSuggestionItem, ComposerSuggestionSeparator, ComposerSuggestionMessage, diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index c424ae6c3..c6829507f 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -8,7 +8,7 @@ import { ChevronRight, Files, Folder as FolderIcon, - Plug, + Unplug, } from "lucide-react"; import { forwardRef, @@ -23,14 +23,12 @@ 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"; -import { - COMPOSIO_CONNECTORS, - OAUTH_CONNECTORS, -} from "@/components/assistant-ui/connector-popup/constants/connector-constants"; +import { getConnectorTitle } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab"; import { ComposerSuggestionGroup, ComposerSuggestionGroupHeading, + ComposerSuggestionHeader, ComposerSuggestionItem, ComposerSuggestionList, ComposerSuggestionMessage, @@ -94,19 +92,6 @@ function useDebounced(value: T, delay = DEBOUNCE_MS) { return debounced; } -function titleForConnectorType(connectorType: string) { - const configured = - OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) || - COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType); - return ( - configured?.title || - connectorType - .replace(/_/g, " ") - .replace(/connector/gi, "") - .trim() - ); -} - function makeDocMention(doc: Pick): MentionedDocumentInfo { return { id: doc.id, @@ -130,7 +115,7 @@ function makeConnectorMention( connector: SearchSourceConnector ): Extract { const accountName = getConnectorDisplayName(connector.name); - const connectorTitle = titleForConnectorType(connector.connector_type); + const connectorTitle = getConnectorTitle(connector.connector_type); return { id: connector.id, title: `${connectorTitle}: ${accountName}`, @@ -319,6 +304,7 @@ export const DocumentMentionPicker = forwardRef< () => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))), [initialSelectedDocuments] ); + const showSurfsenseDocsRootRef = useRef((surfsenseDocs?.items?.length ?? 0) > 0); const selectMention = useCallback( (mention: MentionedDocumentInfo) => { @@ -329,35 +315,41 @@ export const DocumentMentionPicker = forwardRef< ); const rootNodes = useMemo[]>( - () => [ - { - id: "surfsense-docs", - label: "SurfSense Docs", - subtitle: "Browse product documentation", - icon: , - type: "branch", - value: { kind: "view", view: { kind: "surfsense-docs" } }, - }, - { - id: "files-folders", - label: "Files & Folders", - subtitle: "Browse your knowledge base", - icon: , - type: "branch", - value: { kind: "view", view: { kind: "files-folders" } }, - }, - { - id: "connectors", - label: "Connectors", - subtitle: activeConnectors.length - ? "Choose the exact account for tool use" - : "No connected accounts yet", - icon: , - type: "branch", - disabled: activeConnectors.length === 0, - value: { kind: "view", view: { kind: "connectors" } }, - }, - ], + () => { + const nodes: ComposerSuggestionNode[] = []; + if (showSurfsenseDocsRootRef.current) { + nodes.push({ + id: "surfsense-docs", + label: "SurfSense Docs", + subtitle: "Browse product documentation", + icon: , + type: "branch", + value: { kind: "view", view: { kind: "surfsense-docs" } }, + }); + } + nodes.push( + { + id: "files-folders", + label: "Files & Folders", + subtitle: "Browse your knowledge base", + icon: , + type: "branch", + value: { kind: "view", view: { kind: "files-folders" } }, + }, + { + id: "connectors", + label: "Connectors", + subtitle: activeConnectors.length + ? "Choose the exact account for tool use" + : "No connected accounts yet", + icon: , + type: "branch", + disabled: activeConnectors.length === 0, + value: { kind: "view", view: { kind: "connectors" } }, + } + ); + return nodes; + }, [activeConnectors.length] ); @@ -389,7 +381,7 @@ export const DocumentMentionPicker = forwardRef< id: getMentionDocKey(mention), label: mention.title, subtitle: "Connector account", - icon: getConnectorIcon(mention.connector_type, "size-4") ?? , + icon: getConnectorIcon(mention.connector_type, "size-4") ?? , type: "item" as const, disabled: selectedKeys.has(getMentionDocKey(mention)), value: { kind: "mention" as const, mention }, @@ -414,7 +406,7 @@ export const DocumentMentionPicker = forwardRef< byType.set(connector.connector_type, list); } return Array.from(byType.entries()).sort(([a], [b]) => - titleForConnectorType(a).localeCompare(titleForConnectorType(b)) + getConnectorTitle(a).localeCompare(getConnectorTitle(b)) ); }, [activeConnectors]); @@ -459,16 +451,16 @@ export const DocumentMentionPicker = forwardRef< if (view.kind === "connectors") { return connectorTypeEntries.map(([connectorType, typeConnectors]) => ({ id: `connector-type:${connectorType}`, - label: titleForConnectorType(connectorType), + label: getConnectorTitle(connectorType), subtitle: `${typeConnectors.length} ${typeConnectors.length === 1 ? "account" : "accounts"}`, - icon: getConnectorIcon(connectorType, "size-4") ?? , + icon: getConnectorIcon(connectorType, "size-4") ?? , type: "branch" as const, value: { kind: "view" as const, view: { kind: "connector-type" as const, connectorType, - title: titleForConnectorType(connectorType), + title: getConnectorTitle(connectorType), }, }, })); @@ -481,7 +473,7 @@ export const DocumentMentionPicker = forwardRef< id: getMentionDocKey(mention), label: getConnectorDisplayName(connector.name), subtitle: `${view.title} account`, - icon: getConnectorIcon(connector.connector_type, "size-4") ?? , + icon: getConnectorIcon(connector.connector_type, "size-4") ?? , type: "item" as const, disabled: selectedKeys.has(getMentionDocKey(mention)), value: { kind: "mention" as const, mention }, @@ -571,13 +563,20 @@ export const DocumentMentionPicker = forwardRef< {title ? ( <> - } - muted - onClick={handleBack} + + + + } > - {title} - + {title} + ) : null} From 17293125efc5f9bee28aade13ee320add5ba3029 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 27 May 2026 00:26:44 +0530 Subject: [PATCH 08/10] feat(web): implement recent mention management in document mention picker and enhance composer component --- .../components/assistant-ui/thread.tsx | 7 +- .../new-chat/composer-suggestion-popup.tsx | 32 +-- .../new-chat/document-mention-picker.tsx | 271 ++++++++++++++---- .../components/new-chat/prompt-picker.tsx | 6 +- 4 files changed, 243 insertions(+), 73 deletions(-) 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)} From dbf235cbdaf3f418b9531f782b10a8732012634c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 27 May 2026 00:43:50 +0530 Subject: [PATCH 09/10] feat(web): enhance composer suggestion skeleton, improve document mention picker loading states & improved LLM prompt block for connector mentions --- .../app/tasks/chat/stream_new_chat.py | 7 +++--- .../new-chat/composer-suggestion-popup.tsx | 14 +++++++---- .../new-chat/document-mention-picker.tsx | 24 ++++++++++++------- .../components/new-chat/prompt-picker.tsx | 2 +- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 81c801959..1b2a4cfbb 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1401,14 +1401,15 @@ async def stream_new_chat( continue connector_lines.append( f' - connector_id={connector_id}, connector_type="{connector_type}", ' - f'account="{account_name or ""}"' + f'account_name="{account_name or ""}"' ) if connector_lines: context_parts.append( "\n" "The user selected these exact connector accounts with @. " - "For read, write, or HITL tool calls involving these services, " - "prefer the matching connector_id instead of guessing from available accounts:\n" + "These entries are selection metadata, not retrieved connector content. " + "When a connector-backed tool needs an account, use the matching " + "connector_id from this list if the tool supports connector_id:\n" + "\n".join(connector_lines) + "\n" ) diff --git a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx index 2909fbf86..3fdf48875 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-[232px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[264px]", + "w-[232px] select-none 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 )} @@ -145,18 +145,24 @@ function ComposerSuggestionMessage({ ); } -function ComposerSuggestionSkeleton() { +function ComposerSuggestionSkeleton({ + rows = 5, + mobileRows = 3, +}: { + rows?: number; + mobileRows?: number; +}) { return (
- {["a", "b", "c", "d", "e"].map((id, index) => ( + {Array.from({ length: rows }, (_, index) => `skeleton-row-${index}`).map((id, index) => (
= 3 && "hidden sm:flex" + index >= mobileRows && "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 3b96a7cd2..c26e51922 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -438,7 +438,7 @@ export const DocumentMentionPicker = forwardRef< () => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))), [initialSelectedDocuments] ); - const showSurfsenseDocsRootRef = useRef((surfsenseDocs?.items?.length ?? 0) > 0); + const showSurfsenseDocsRoot = surfsenseDocsList.length > 0; const selectMention = useCallback( (mention: MentionedDocumentInfo) => { @@ -463,7 +463,7 @@ export const DocumentMentionPicker = forwardRef< const rootNodes = useMemo[]>( () => { const nodes: ComposerSuggestionNode[] = [...recentRootNodes]; - if (showSurfsenseDocsRootRef.current) { + if (showSurfsenseDocsRoot) { nodes.push({ id: "surfsense-docs", label: "SurfSense Docs", @@ -496,7 +496,7 @@ export const DocumentMentionPicker = forwardRef< ); return nodes; }, - [activeConnectors.length, recentRootNodes] + [activeConnectors.length, recentRootNodes, showSurfsenseDocsRoot] ); const searchNodes = useMemo[]>(() => { @@ -680,11 +680,18 @@ export const DocumentMentionPicker = forwardRef< [canLoadMoreDocuments, hasMore, isLoadingMore, loadNextPage] ); + const isRootBrowseView = !hasSearch && view.kind === "root"; + const isVisibleViewLoading = hasSearch + ? isTitleSearchLoading || isSurfsenseDocsLoading || isConnectorsLoading + : view.kind === "surfsense-docs" + ? isSurfsenseDocsLoading + : view.kind === "files-folders" + ? isTitleSearchLoading + : view.kind === "connectors" || view.kind === "connector-type" + ? isConnectorsLoading + : false; const actualLoading = - (isTitleSearchLoading || isSurfsenseDocsLoading || isConnectorsLoading) && - !isSingleCharSearch && - visibleNodes.length === 0 && - (view.kind === "root" || hasSearch); + isVisibleViewLoading && !isSingleCharSearch && visibleNodes.length === 0 && !isRootBrowseView; const title = hasSearch || view.kind === "root" @@ -703,9 +710,10 @@ export const DocumentMentionPicker = forwardRef< onScroll={handleScroll} role="listbox" tabIndex={-1} + className={isRootBrowseView ? "max-h-none overflow-visible sm:max-h-none" : undefined} > {actualLoading ? ( - + ) : ( {title ? ( diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index 54d44662c..986a5d608 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -127,7 +127,7 @@ export const PromptPicker = forwardRef(funct return ( {isLoading ? ( - + ) : isError ? ( Failed to load prompts ) : filtered.length === 0 ? ( From b8450e60d5f2b7fa61d725afa3b2a24c982c7f39 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 27 May 2026 01:41:37 +0530 Subject: [PATCH 10/10] refactor(web): update Switch component styles in ComposerAction for improved layout and consistency --- surfsense_web/components/assistant-ui/thread.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 9ea8bc982..5c5f99940 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1077,7 +1077,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false )} @@ -1317,7 +1317,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false )} @@ -1367,7 +1367,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false ); @@ -1419,7 +1419,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onCheckedChange={() => toggleToolGroup(toolNames)} - className="shrink-0 scale-[0.6]" + className="mr-2 shrink-0 origin-right scale-[0.6]" /> @@ -1448,7 +1448,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false ); @@ -1488,7 +1488,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false );