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 + + + )} + ); });