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"; }; /**