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..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,18 +215,18 @@ 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( + 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. 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." + "knowledge-base ``Folder`` row, and ``connector`` is a " + "concrete connected account." ), ) + 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 +264,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 +345,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..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,27 @@ 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") 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" + 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), + "kind": kind, + } + if document_type is not None: + item["document_type"] = str(document_type) + if kind == "connector": + 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) + 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..1b2a4cfbb 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,33 @@ 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_name="{account_name or ""}"' + ) + if connector_lines: + context_parts.append( + "\n" + "The user selected these exact connector accounts with @. " + "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" + ) + # 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 +1807,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..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,11 +207,13 @@ 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")]) + .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 +230,30 @@ 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, + kind: "connector", + connector_type: doc.connector_type ?? doc.document_type ?? "UNKNOWN", + account_name: doc.account_name ?? doc.title, + }; + } + if (doc.kind === "folder") { + return { + id: doc.id, + title: doc.title, + kind: "folder", + }; + } + return { + id: doc.id, + title: doc.title, + document_type: doc.document_type ?? "UNKNOWN", + kind: "doc", + }; + }); } } @@ -924,28 +950,22 @@ 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, }); // 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({ - id: doc.id, - title: doc.title, - document_type: doc.document_type, - kind: doc.kind, - }); + allMentionedDocs.push(doc); } if (allMentionedDocs.length > 0) { @@ -1008,9 +1028,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 +1057,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 +1962,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 +1975,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..25d1e397a 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -3,28 +3,32 @@ 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. * - * 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; + kind: "folder"; + } + | { + id: number; + title: string; + kind: "connector"; + connector_type: string; + account_name: string; + }; /** * Backwards-compatible doc-only chip shape for legacy callers that @@ -38,13 +42,15 @@ 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 ): 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 { @@ -62,43 +68,53 @@ 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; }); 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, + kind: c.kind, + connector_type: c.connector_type, + account_name: c.account_name, + })), }; }); 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 e62b9546a..5de623c22 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 @@ -229,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/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 2c8ad6263..c0d9d9212 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 { @@ -20,31 +20,44 @@ 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"; 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; } /** * 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; title: string; document_type?: string; kind?: MentionKind; + connector_type?: string; + account_name?: string; +}; + +export type SuggestionAnchorRect = { + left: number; + top: number; + bottom: number; +}; + +export type SuggestionTriggerInfo = { + query: string; + anchorRect: SuggestionAnchorRect | null; }; export interface InlineMentionEditorRef { @@ -62,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, @@ -73,13 +91,13 @@ 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; - 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; @@ -95,6 +113,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: "" }]; @@ -117,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); @@ -134,6 +159,7 @@ const MentionElement: FC> = ({ : "text-amber-700"; const isFolder = element.kind === "folder"; + const isConnector = element.kind === "connector"; const ctx = useContext(MentionEditorContext); return ( @@ -144,24 +170,35 @@ 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") )} {ctx ? ( - + + ) : null} @@ -228,6 +265,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); } @@ -299,6 +338,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 +429,19 @@ 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"); }; @@ -485,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] ); @@ -610,7 +705,7 @@ export const InlineMentionEditor = forwardRef {icon} {label} - + ); if (!tooltip) return chip; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index c4f6fed05..5c5f99940 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -62,13 +62,17 @@ import { InlineMentionEditor, type InlineMentionEditorRef, type MentionedDocument, + type SuggestionAnchorRect, + type SuggestionTriggerInfo, } 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, + promoteRecentMention, type DocumentMentionPickerRef, -} from "@/components/new-chat/document-mention-picker"; +} from "../new-chat/document-mention-picker"; import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -90,6 +94,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"; @@ -110,6 +115,34 @@ import { cn } from "@/lib/utils"; const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs"; +type ComposerSuggestionAnchorPoint = { + left: number; + top: number; +}; + +function ComposerSuggestionAnchor({ point }: { point: ComposerSuggestionAnchorPoint }) { + return ( + + ); +} + +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 ; }; @@ -409,6 +442,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); @@ -489,6 +524,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 @@ -507,44 +543,96 @@ const Composer: FC = () => { 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) => { + if (d.kind === "connector") { + return { + id: d.id, + title: d.title, + kind: "connector", + connector_type: d.connector_type ?? "UNKNOWN", + account_name: d.account_name ?? d.title, + }; + } + if (d.kind === "folder") { + return { + id: d.id, + title: d.title, + kind: "folder", + }; + } + return { + id: d.id, + title: d.title, + document_type: d.document_type ?? "UNKNOWN", + kind: "doc", + }; + }); }); }, [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((current) => current ?? anchorPoint); setShowDocumentPopover(true); - setMentionQuery(query); + setMentionQuery(trigger.query); }, []); const handleMentionClose = useCallback(() => { if (showDocumentPopover) { setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); } }, [showDocumentPopover]); - const handleActionTrigger = useCallback((query: string) => { - setShowPromptPicker(true); - setActionQuery(query); + const handleDocumentPopoverOpenChange = useCallback((open: boolean) => { + setShowDocumentPopover(open); + if (!open) { + setMentionQuery(""); + setSuggestionAnchorPoint(null); + } }, []); + const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => { + const anchorPoint = getComposerSuggestionAnchorPoint( + trigger.anchorRect, + clipboardInitialText ? "bottom" : "top" + ); + if (!anchorPoint) { + setShowPromptPicker(false); + setActionQuery(""); + setSuggestionAnchorPoint(null); + return; + } + setSuggestionAnchorPoint((current) => current ?? anchorPoint); + setShowPromptPicker(true); + 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(""); + setSuggestionAnchorPoint(null); + } + }, []); + const handleActionSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { let userText = editorRef.current?.getText() ?? ""; @@ -561,6 +649,7 @@ const Composer: FC = () => { aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); }, [actionQuery, aui] ); @@ -576,6 +665,7 @@ const Composer: FC = () => { aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); setClipboardInitialText(undefined); }, [clipboardInitialText, electronAPI, aui] @@ -604,6 +694,7 @@ const Composer: FC = () => { e.preventDefault(); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); return; } } @@ -625,8 +716,12 @@ const Composer: FC = () => { } if (e.key === "Escape") { e.preventDefault(); + if (documentPickerRef.current?.goBack()) { + return; + } setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); return; } } @@ -659,13 +754,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); }); }, @@ -673,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))); @@ -680,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); } @@ -687,7 +787,8 @@ const Composer: FC = () => { // Atom is reconciled by ``handleEditorChange`` via the editor's // onChange — no second write path here. setMentionQuery(""); - }, []); + setSuggestionAnchorPoint(null); + }, [search_space_id]); useEffect(() => { const editor = editorRef.current; @@ -708,7 +809,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 + ); } } @@ -723,39 +829,46 @@ const Composer: FC = () => { currentUserId={currentUser?.id ?? null} members={members ?? []} /> - {showDocumentPopover && ( -
- { - setShowDocumentPopover(false); - setMentionQuery(""); - }} - initialSelectedDocuments={mentionedDocuments} - externalSearch={mentionQuery} - /> -
- )} - {showPromptPicker && ( -
- { - setShowPromptPicker(false); - setActionQuery(""); - }} - externalSearch={actionQuery} - /> -
- )} + + {suggestionAnchorPoint ? ( + <> + + + { + setShowDocumentPopover(false); + setMentionQuery(""); + setSuggestionAnchorPoint(null); + }} + initialSelectedDocuments={mentionedDocuments} + externalSearch={mentionQuery} + /> + + + ) : null} + + + {suggestionAnchorPoint ? ( + <> + + + { + setShowPromptPicker(false); + setActionQuery(""); + setSuggestionAnchorPoint(null); + }} + externalSearch={actionQuery} + /> + + + ) : null} +
{ onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} - className="min-h-[24px]" + className="min-h-[24px] **:data-slate-placeholder:font-normal" />
@@ -964,7 +1077,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false )} @@ -1037,9 +1150,10 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false >
- + = ({ isBlockedByOtherUser = false )} @@ -1253,7 +1367,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false ); @@ -1305,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]" /> @@ -1334,7 +1448,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false ); @@ -1374,7 +1488,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false ); diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index d17788c71..b30db5f69 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, "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/auth/sign-in-button.tsx b/surfsense_web/components/auth/sign-in-button.tsx index 688dd496a..7f5a77f36 100644 --- a/surfsense_web/components/auth/sign-in-button.tsx +++ b/surfsense_web/components/auth/sign-in-button.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useState } from "react"; +import { Button } from "@/components/ui/button"; import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; @@ -74,8 +75,9 @@ export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => { if (isGoogleAuth) { return ( - + ); } 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/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/composer-suggestion-popup.tsx b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx new file mode 100644 index 000000000..3fdf48875 --- /dev/null +++ b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx @@ -0,0 +1,190 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { PopoverContent } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +function ComposerSuggestionPopoverContent({ + className, + align = "start", + sideOffset = 6, + collisionPadding = 12, + onOpenAutoFocus, + onCloseAutoFocus, + style, + ...props +}: React.ComponentProps) { + return ( + { + event.preventDefault(); + onOpenAutoFocus?.(event); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + onCloseAutoFocus?.(event); + }} + className={cn( + "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 + )} + style={{ ...style, animation: "none" }} + {...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 ( +
+ ); +} + +function ComposerSuggestionHeader({ + className, + icon, + children, + ...props +}: React.HTMLAttributes & { icon?: React.ReactNode }) { + return ( +
+ {icon ? {icon} : null} + {children} +
+ ); +} + +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({ + rows = 5, + mobileRows = 3, +}: { + rows?: number; + mobileRows?: number; +}) { + return ( +
+
+ +
+ {Array.from({ length: rows }, (_, index) => `skeleton-row-${index}`).map((id, index) => ( +
= mobileRows && "hidden sm:flex" + )} + > + + + + + + +
+ ))} +
+ ); +} + +export { + ComposerSuggestionPopoverContent, + ComposerSuggestionList, + ComposerSuggestionGroup, + ComposerSuggestionGroupHeading, + ComposerSuggestionHeader, + 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..c26e51922 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -2,35 +2,54 @@ 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, + Unplug, +} from "lucide-react"; +import { + Fragment, forwardRef, + type UIEvent, useCallback, useDeferredValue, useEffect, - useImperativeHandle, useMemo, useRef, useState, } from "react"; +import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom"; +import { useAtomValue } from "jotai"; +import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; +import { getConnectorTitle } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; +import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab"; 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"; + ComposerSuggestionGroup, + ComposerSuggestionGroupHeading, + ComposerSuggestionHeader, + ComposerSuggestionItem, + ComposerSuggestionList, + ComposerSuggestionMessage, + 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 { cn } from "@/lib/utils"; import { queries } from "@/zero/queries"; -export interface DocumentMentionPickerRef { - selectHighlighted: () => void; - moveUp: () => void; - moveDown: () => void; -} +export type DocumentMentionPickerRef = ComposerSuggestionNavigatorRef; interface DocumentMentionPickerProps { searchSpaceId: number; @@ -43,35 +62,165 @@ 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" } + | { 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 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; +} -/** - * 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. - */ 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 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 } +): Extract { + return { + id: folder.id, + title: folder.title, + kind: "folder", + }; +} + +function makeConnectorMention( + connector: SearchSourceConnector +): Extract { + const accountName = getConnectorDisplayName(connector.name); + const connectorTitle = getConnectorTitle(connector.connector_type); + return { + id: connector.id, + title: `${connectorTitle}: ${accountName}`, + kind: "connector", + connector_type: connector.connector_type, + account_name: accountName, + }; +} + +function mentionMatchesSearch(mention: MentionedDocumentInfo, searchLower: string) { + return [ + mention.title, + 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)); +} + export const DocumentMentionPicker = forwardRef< DocumentMentionPickerRef, DocumentMentionPickerProps @@ -79,51 +228,49 @@ 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[] >([]); const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); + const [recentMentions, setRecentMentions] = useState(() => + readRecentMentions(searchSpaceId) + ); - // 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 activeConnectors = useMemo(() => connectors.filter(isConnectorActive), [connectors]); + 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]); + + useEffect(() => { + setRecentMentions(readRecentMentions(searchSpaceId)); + }, [searchSpaceId]); - // Query parameters for lightweight title search endpoint const titleSearchParams = useMemo( () => ({ search_space_id: searchSpaceId, @@ -139,57 +286,41 @@ 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) { + 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({ @@ -206,10 +337,8 @@ export const DocumentMentionPicker = forwardRef< } setAccumulatedDocuments(filterBySearchTerm(combinedDocs)); - } }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]); - // Load next page for infinite scroll pagination const loadNextPage = useCallback(async () => { if (isLoadingMore || !hasMore) return; @@ -223,9 +352,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); @@ -237,41 +366,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] @@ -280,368 +380,424 @@ 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( + () => 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] + ); - // 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] ); + const showSurfsenseDocsRoot = surfsenseDocsList.length > 0; - // 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(); }, [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]); - } - }, - moveUp: () => { - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1)); - }, - moveDown: () => { - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0)); - }, - }), - [selectableMentions, highlightedIndex, handleSelectMention] + 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] ); - // Keyboard navigation handler for arrow keys, Enter, and Escape - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (selectableMentions.length === 0) return; + const rootNodes = useMemo[]>( + () => { + const nodes: ComposerSuggestionNode[] = [...recentRootNodes]; + if (showSurfsenseDocsRoot) { + 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, recentRootNodes, showSurfsenseDocsRoot] + ); - 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; + 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.connector_type, "size-4") ?? , + type: "item" as const, + disabled: selectedKeys.has(getMentionDocKey(mention)), + value: { kind: "mention" as const, mention }, + })); + + return [...docNodes, ...folderNodes, ...connectorNodes]; + }, [ + actualDocuments, + connectorMentions, + debouncedSearch, + deferredSearch, + folderMentions, + isSingleCharSearch, + selectedKeys, + ]); + + const connectorTypeEntries = useMemo(() => { + const byType = new Map(); + for (const connector of activeConnectors) { + const list = byType.get(connector.connector_type) ?? []; + list.push(connector); + byType.set(connector.connector_type, list); + } + return Array.from(byType.entries()).sort(([a], [b]) => + getConnectorTitle(a).localeCompare(getConnectorTitle(b)) + ); + }, [activeConnectors]); + + 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: getConnectorTitle(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: getConnectorTitle(connectorType), + }, + }, + })); + } + return activeConnectors + .filter((connector) => 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 }, + }; + }); + }, [ + activeConnectors, + 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 canLoadMoreDocuments = hasSearch || view.kind === "files-folders"; + + const handleScroll = useCallback( + (e: UIEvent) => { + if (!canLoadMoreDocuments) return; + const target = e.currentTarget; + const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + + if (scrollBottom < 50 && hasMore && !isLoadingMore) { + loadNextPage(); } }, - [selectableMentions, highlightedIndex, handleSelectMention, onDone] + [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 = + isVisibleViewLoading && !isSingleCharSearch && visibleNodes.length === 0 && !isRootBrowseView; + + 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 ( -
- {/* Scrollable document list with responsive height */} -
- {actualLoading ? ( -
-
- -
- {["a", "b", "c", "d", "e"].map((id, i) => ( -
= 3 && "hidden sm:flex" - )} + {actualLoading ? ( + + ) : ( + + {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={ + + + + } > - - - - - - -
- ))} -
- ) : 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; + {title} + + + + ) : null} - return ( - - ); - })} - - )} + + + {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 ( - - ); - })} - - )} - - {/* 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

-
- )} -
-
+ {canLoadMoreDocuments && isLoadingMore && ( +
+ +
+ )} + + )} + ); }); diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index 1cb9f80f5..986a5d608 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 + + + )} + ); }); 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..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; - kind?: "doc" | "folder"; + 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 396097445..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 { 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`;