diff --git a/surfsense_backend/app/agents/new_chat/context.py b/surfsense_backend/app/agents/new_chat/context.py
index a20a43a66..1b3ea3d20 100644
--- a/surfsense_backend/app/agents/new_chat/context.py
+++ b/surfsense_backend/app/agents/new_chat/context.py
@@ -64,6 +64,8 @@ class SurfSenseContextSchema:
search_space_id: int | None = None
mentioned_document_ids: list[int] = field(default_factory=list)
mentioned_folder_ids: list[int] = field(default_factory=list)
+ mentioned_connector_ids: list[int] = field(default_factory=list)
+ mentioned_connectors: list[dict[str, object]] = field(default_factory=list)
file_operation_contract: FileOperationContractState | None = None
turn_id: str | None = None
request_id: str | None = None
diff --git a/surfsense_backend/app/agents/new_chat/mention_resolver.py b/surfsense_backend/app/agents/new_chat/mention_resolver.py
index 00bb7e71f..6a025b947 100644
--- a/surfsense_backend/app/agents/new_chat/mention_resolver.py
+++ b/surfsense_backend/app/agents/new_chat/mention_resolver.py
@@ -134,7 +134,7 @@ async def resolve_mentions(
kind = chip.kind
if kind == "folder":
chip_folder_ids.append(chip.id)
- else:
+ elif kind == "doc":
chip_doc_ids.append(chip.id)
chip_titles_by_id[(kind, chip.id)] = chip.title
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index 44fc1c392..fb4d5a049 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -1771,6 +1771,11 @@ async def handle_new_chat(
if request.mentioned_documents
else None
)
+ mentioned_connectors_payload = (
+ [doc.model_dump() for doc in request.mentioned_connectors]
+ if request.mentioned_connectors
+ else None
+ )
return StreamingResponse(
stream_new_chat(
@@ -1782,6 +1787,8 @@ async def handle_new_chat(
mentioned_document_ids=request.mentioned_document_ids,
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
mentioned_folder_ids=request.mentioned_folder_ids,
+ mentioned_connector_ids=request.mentioned_connector_ids,
+ mentioned_connectors=mentioned_connectors_payload,
mentioned_documents=mentioned_documents_payload,
needs_history_bootstrap=thread.needs_history_bootstrap,
thread_visibility=thread.visibility,
@@ -2258,6 +2265,11 @@ async def regenerate_response(
if request.mentioned_documents
else None
)
+ mentioned_connectors_payload = (
+ [doc.model_dump() for doc in request.mentioned_connectors]
+ if request.mentioned_connectors
+ else None
+ )
try:
async for chunk in stream_new_chat(
user_query=str(user_query_to_use),
@@ -2268,6 +2280,8 @@ async def regenerate_response(
mentioned_document_ids=request.mentioned_document_ids,
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
mentioned_folder_ids=request.mentioned_folder_ids,
+ mentioned_connector_ids=request.mentioned_connector_ids,
+ mentioned_connectors=mentioned_connectors_payload,
mentioned_documents=mentioned_documents_payload,
checkpoint_id=target_checkpoint_id,
needs_history_bootstrap=thread.needs_history_bootstrap,
diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py
index c5315cce5..c721f495e 100644
--- a/surfsense_backend/app/schemas/new_chat.py
+++ b/surfsense_backend/app/schemas/new_chat.py
@@ -218,17 +218,20 @@ class MentionedDocumentInfo(BaseModel):
id: int
title: str = Field(..., min_length=1, max_length=500)
document_type: str = Field(..., min_length=1, max_length=100)
- kind: Literal["doc", "folder"] = Field(
+ kind: Literal["doc", "folder", "connector"] = Field(
default="doc",
description=(
"Discriminator for the chip's referent: ``doc`` is a "
"knowledge-base ``Document`` row, ``folder`` is a "
- "knowledge-base ``Folder`` row. Folders carry the sentinel "
+ "knowledge-base ``Folder`` row, and ``connector`` is a "
+ "concrete connected account. Folders carry the sentinel "
"``document_type='FOLDER'`` to keep the frontend dedup key "
"``(kind:document_type:id)`` from colliding doc and folder "
"ids that happen to share an integer value."
),
)
+ connector_type: str | None = Field(default=None, max_length=100)
+ account_name: str | None = Field(default=None, max_length=255)
class NewChatRequest(BaseModel):
@@ -266,6 +269,18 @@ class NewChatRequest(BaseModel):
"a mentioned-documents part."
),
)
+ mentioned_connector_ids: list[int] | None = Field(
+ default=None,
+ description="Optional concrete connector account IDs the user @-mentioned.",
+ )
+ mentioned_connectors: list[MentionedDocumentInfo] | None = Field(
+ default=None,
+ description=(
+ "Display/context metadata for selected connector accounts. "
+ "Kept separate from document/folder id arrays so tools can "
+ "prefer the exact account the user selected."
+ ),
+ )
disabled_tools: list[str] | None = (
None # Optional list of tool names the user has disabled from the UI
)
@@ -335,6 +350,8 @@ class RegenerateRequest(BaseModel):
"new user message. None means no chip metadata."
),
)
+ mentioned_connector_ids: list[int] | None = None
+ mentioned_connectors: list[MentionedDocumentInfo] | None = None
disabled_tools: list[str] | None = None
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web"
diff --git a/surfsense_backend/app/tasks/chat/persistence.py b/surfsense_backend/app/tasks/chat/persistence.py
index 37be50705..07266cf69 100644
--- a/surfsense_backend/app/tasks/chat/persistence.py
+++ b/surfsense_backend/app/tasks/chat/persistence.py
@@ -137,15 +137,19 @@ def _build_user_content(
if doc_id is None or title is None or document_type is None:
continue
kind_raw = doc.get("kind", "doc")
- kind = kind_raw if kind_raw in ("doc", "folder") else "doc"
- normalized.append(
- {
- "id": doc_id,
- "title": str(title),
- "document_type": str(document_type),
- "kind": kind,
- }
- )
+ kind = kind_raw if kind_raw in ("doc", "folder", "connector") else "doc"
+ item = {
+ "id": doc_id,
+ "title": str(title),
+ "document_type": str(document_type),
+ "kind": kind,
+ }
+ if kind == "connector":
+ connector_type = doc.get("connector_type") or document_type
+ account_name = doc.get("account_name") or title
+ item["connector_type"] = str(connector_type)
+ item["account_name"] = str(account_name)
+ normalized.append(item)
if normalized:
parts.append({"type": "mentioned-documents", "documents": normalized})
return parts
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index fee50d72d..81c801959 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -839,6 +839,8 @@ async def stream_new_chat(
mentioned_document_ids: list[int] | None = None,
mentioned_surfsense_doc_ids: list[int] | None = None,
mentioned_folder_ids: list[int] | None = None,
+ mentioned_connector_ids: list[int] | None = None,
+ mentioned_connectors: list[dict[str, Any]] | None = None,
mentioned_documents: list[dict[str, Any]] | None = None,
checkpoint_id: str | None = None,
needs_history_bootstrap: bool = False,
@@ -1385,6 +1387,32 @@ async def stream_new_chat(
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
)
+ if mentioned_connectors:
+ connector_lines = []
+ for connector in mentioned_connectors:
+ if not isinstance(connector, dict):
+ continue
+ connector_id = connector.get("id")
+ connector_type = connector.get("connector_type") or connector.get(
+ "document_type"
+ )
+ account_name = connector.get("account_name") or connector.get("title")
+ if connector_id is None or connector_type is None:
+ continue
+ connector_lines.append(
+ f' - connector_id={connector_id}, connector_type="{connector_type}", '
+ f'account="{account_name or ""}"'
+ )
+ if connector_lines:
+ context_parts.append(
+ "\n"
+ "The user selected these exact connector accounts with @. "
+ "For read, write, or HITL tool calls involving these services, "
+ "prefer the matching connector_id instead of guessing from available accounts:\n"
+ + "\n".join(connector_lines)
+ + "\n"
+ )
+
# Surface report IDs prominently so the LLM doesn't have to
# retrieve them from old tool responses in conversation history.
if recent_reports:
@@ -1778,6 +1806,8 @@ async def stream_new_chat(
mentioned_folder_ids=list(
accepted_folder_ids or mentioned_folder_ids or []
),
+ mentioned_connector_ids=list(mentioned_connector_ids or []),
+ mentioned_connectors=list(mentioned_connectors or []),
request_id=request_id,
turn_id=stream_result.turn_id,
)
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index ecd5ab6b1..8d1f5da46 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -208,9 +208,11 @@ const MentionedDocumentInfoSchema = z.object({
title: z.string(),
document_type: z.string(),
kind: z
- .union([z.literal("doc"), z.literal("folder")])
+ .union([z.literal("doc"), z.literal("folder"), z.literal("connector")])
.optional()
.default("doc"),
+ connector_type: z.string().optional(),
+ account_name: z.string().optional(),
});
const MentionedDocumentsPartSchema = z.object({
@@ -227,7 +229,32 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
for (const part of content) {
const result = MentionedDocumentsPartSchema.safeParse(part);
if (result.success) {
- return result.data.documents;
+ return result.data.documents.map((doc) => {
+ if (doc.kind === "connector") {
+ return {
+ id: doc.id,
+ title: doc.title,
+ document_type: doc.document_type,
+ kind: "connector",
+ connector_type: doc.connector_type ?? doc.document_type,
+ account_name: doc.account_name ?? doc.title,
+ };
+ }
+ if (doc.kind === "folder") {
+ return {
+ id: doc.id,
+ title: doc.title,
+ document_type: "FOLDER",
+ kind: "folder",
+ };
+ }
+ return {
+ id: doc.id,
+ title: doc.title,
+ document_type: doc.document_type,
+ kind: "doc",
+ };
+ });
}
}
@@ -924,7 +951,8 @@ export default function NewChatPage() {
hasMentionedDocuments:
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
mentionedDocumentIds.document_ids.length > 0 ||
- mentionedDocumentIds.folder_ids.length > 0,
+ mentionedDocumentIds.folder_ids.length > 0 ||
+ mentionedDocumentIds.connector_ids.length > 0,
messageLength: userQuery.length,
});
@@ -940,12 +968,7 @@ export default function NewChatPage() {
const key = `${doc.kind}:${doc.document_type}:${doc.id}`;
if (seenDocKeys.has(key)) continue;
seenDocKeys.add(key);
- allMentionedDocs.push({
- id: doc.id,
- title: doc.title,
- document_type: doc.document_type,
- kind: doc.kind,
- });
+ allMentionedDocs.push(doc);
}
if (allMentionedDocs.length > 0) {
@@ -1008,9 +1031,10 @@ export default function NewChatPage() {
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
+ const hasConnectorIds = mentionedDocumentIds.connector_ids.length > 0;
// Clear mentioned documents after capturing them
- if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds) {
+ if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds || hasConnectorIds) {
setMentionedDocuments([]);
}
@@ -1036,20 +1060,16 @@ export default function NewChatPage() {
? mentionedDocumentIds.surfsense_doc_ids
: undefined,
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
+ mentioned_connector_ids: hasConnectorIds
+ ? mentionedDocumentIds.connector_ids
+ : undefined,
+ mentioned_connectors: hasConnectorIds ? mentionedDocumentIds.connectors : undefined,
// Full mention metadata (docs + folders, with
// ``kind`` discriminator) so the BE can embed a
// ``mentioned-documents`` ContentPart on the
// persisted user message (replaces the old FE-side
// injection in ``persistUserTurn``).
- mentioned_documents:
- allMentionedDocs.length > 0
- ? allMentionedDocs.map((d) => ({
- id: d.id,
- title: d.title,
- document_type: d.document_type,
- kind: d.kind,
- }))
- : undefined,
+ mentioned_documents: allMentionedDocs.length > 0 ? allMentionedDocs : undefined,
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
...(userImages.length > 0 ? { user_images: userImages } : {}),
}),
@@ -1945,6 +1965,7 @@ export default function NewChatPage() {
const regenerateFolderIds = sourceMentionedDocs
.filter((d) => d.kind === "folder")
.map((d) => d.id);
+ const regenerateConnectors = sourceMentionedDocs.filter((d) => d.kind === "connector");
const requestBody: Record = {
search_space_id: searchSpaceId,
@@ -1957,19 +1978,16 @@ export default function NewChatPage() {
mentioned_surfsense_doc_ids:
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
+ mentioned_connector_ids:
+ regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
+ mentioned_connectors:
+ regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
// Full mention metadata for the regenerate-specific
// source list. Only meaningful for edit (the BE only
// re-persists a user row when ``user_query`` is set);
// reload reuses the original turn's mentioned_documents.
mentioned_documents:
- sourceMentionedDocs.length > 0
- ? sourceMentionedDocs.map((d) => ({
- id: d.id,
- title: d.title,
- document_type: d.document_type,
- kind: d.kind,
- }))
- : undefined,
+ sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
};
if (isEdit) {
requestBody.user_images = editExtras?.userImages ?? [];
diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts
index 9163960f4..9efd2b7fe 100644
--- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts
+++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts
@@ -13,18 +13,31 @@ export const FOLDER_MENTION_DOCUMENT_TYPE = "FOLDER";
/**
* Display metadata for a single ``@``-mention chip.
*
- * The ``kind`` discriminator identifies whether the chip is a
- * knowledge-base document or a knowledge-base folder. Folders carry
- * the sentinel ``document_type === FOLDER_MENTION_DOCUMENT_TYPE`` so
- * the editor, picker, and persisted ``mentioned-documents`` content
- * part all stay aligned with the backend Pydantic schema.
+ * Historical name is retained because this atom is already wired into
+ * chat persistence and sidebar selection. The shape is now the selected
+ * composer context, not only documents.
*/
-export interface MentionedDocumentInfo {
- id: number;
- title: string;
- document_type: string;
- kind: "doc" | "folder";
-}
+export type MentionedDocumentInfo =
+ | {
+ id: number;
+ title: string;
+ document_type: string;
+ kind: "doc";
+ }
+ | {
+ id: number;
+ title: string;
+ document_type: typeof FOLDER_MENTION_DOCUMENT_TYPE;
+ kind: "folder";
+ }
+ | {
+ id: number;
+ title: string;
+ document_type: string;
+ kind: "connector";
+ connector_type: string;
+ account_name: string;
+ };
/**
* Backwards-compatible doc-only chip shape for legacy callers that
@@ -44,7 +57,10 @@ type LegacyDocMention = Pick;
export function toMentionedDocumentInfo(
input: LegacyDocMention | MentionedDocumentInfo
): MentionedDocumentInfo {
- if ("kind" in input && (input.kind === "doc" || input.kind === "folder")) {
+ if (
+ "kind" in input &&
+ (input.kind === "doc" || input.kind === "folder" || input.kind === "connector")
+ ) {
return input;
}
return {
@@ -93,12 +109,22 @@ export const mentionedDocumentIdsAtom = atom((get) => {
});
const docs = deduped.filter((m) => m.kind === "doc");
const folders = deduped.filter((m) => m.kind === "folder");
+ const connectors = deduped.filter((m) => m.kind === "connector");
return {
surfsense_doc_ids: docs
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: docs.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
folder_ids: folders.map((f) => f.id),
+ connector_ids: connectors.map((c) => c.id),
+ connectors: connectors.map((c) => ({
+ id: c.id,
+ title: c.title,
+ document_type: c.document_type,
+ kind: c.kind,
+ connector_type: c.connector_type,
+ account_name: c.account_name,
+ })),
};
});
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
index 67466532e..b93ea253d 100644
--- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
+++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Folder as FolderIcon, X as XIcon } from "lucide-react";
+import { Folder as FolderIcon, Plug as PlugIcon, X as XIcon } from "lucide-react";
import type { NodeEntry, TElement } from "platejs";
import type { PlateElementProps } from "platejs/react";
import {
@@ -27,13 +27,15 @@ import type { Document } from "@/contracts/types/document.types";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
-export type MentionKind = "doc" | "folder";
+export type MentionKind = "doc" | "folder" | "connector";
export interface MentionedDocument {
id: number;
title: string;
document_type?: string;
kind: MentionKind;
+ connector_type?: string;
+ account_name?: string;
}
/**
@@ -46,6 +48,8 @@ export type MentionChipInput = {
title: string;
document_type?: string;
kind?: MentionKind;
+ connector_type?: string;
+ account_name?: string;
};
export type SuggestionAnchorRect = {
@@ -107,6 +111,8 @@ type MentionElementNode = {
document_type?: string;
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */
kind?: MentionKind;
+ connector_type?: string;
+ account_name?: string;
statusLabel?: string | null;
statusKind?: MentionStatusKind;
children: [{ text: "" }];
@@ -146,6 +152,7 @@ const MentionElement: FC> = ({
: "text-amber-700";
const isFolder = element.kind === "folder";
+ const isConnector = element.kind === "connector";
const ctx = useContext(MentionEditorContext);
return (
@@ -156,6 +163,10 @@ const MentionElement: FC> = ({
{isFolder ? (
+ ) : isConnector ? (
+ getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? (
+
+ )
) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
@@ -242,6 +253,8 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
title: node.title,
document_type: node.document_type,
kind,
+ connector_type: node.connector_type,
+ account_name: node.account_name,
};
map.set(getMentionDocKey(doc), doc);
}
@@ -444,13 +457,20 @@ export const InlineMentionEditor = forwardRef {
return prev;
}
}
- return docs.map((d) => ({
- id: d.id,
- title: d.title,
- // Atom requires a string; ``"UNKNOWN"`` matches the
- // sentinel ``getMentionDocKey`` and the editor's
- // match predicates use.
- document_type: d.document_type ?? "UNKNOWN",
- kind: d.kind,
- }));
+ return docs.map((d) => {
+ const documentType = d.document_type ?? "UNKNOWN";
+ if (d.kind === "connector") {
+ return {
+ id: d.id,
+ title: d.title,
+ document_type: documentType,
+ kind: "connector",
+ connector_type: d.connector_type ?? documentType,
+ account_name: d.account_name ?? d.title,
+ };
+ }
+ if (d.kind === "folder") {
+ return {
+ id: d.id,
+ title: d.title,
+ document_type: FOLDER_MENTION_DOCUMENT_TYPE,
+ kind: "folder",
+ };
+ }
+ return {
+ id: d.id,
+ title: d.title,
+ // Atom requires a string; ``"UNKNOWN"`` matches the
+ // sentinel ``getMentionDocKey`` and the editor's
+ // match predicates use.
+ document_type: documentType,
+ kind: "doc",
+ };
+ });
});
},
[aui, setMentionedDocuments]
@@ -700,6 +722,9 @@ const Composer: FC = () => {
}
if (e.key === "Escape") {
e.preventDefault();
+ if (documentPickerRef.current?.goBack()) {
+ return;
+ }
setShowDocumentPopover(false);
setMentionQuery("");
setSuggestionAnchorPoint(null);
diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx
index d17788c71..3e6dc829a 100644
--- a/surfsense_web/components/assistant-ui/user-message.tsx
+++ b/surfsense_web/components/assistant-ui/user-message.tsx
@@ -6,7 +6,7 @@ import {
useMessagePartText,
} from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
-import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react";
+import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil, Plug } from "lucide-react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { type FC, useCallback, useState } from "react";
@@ -100,8 +100,13 @@ const UserTextPart: FC = () => {
return {segment.value};
}
const isFolder = segment.doc.kind === "folder";
+ const isConnector = segment.doc.kind === "connector";
const icon = isFolder ? (
+ ) : isConnector ? (
+ getConnectorIcon(segment.doc.connector_type ?? segment.doc.document_type, "size-3.5") ?? (
+
+ )
) : (
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
);
@@ -110,8 +115,16 @@ const UserTextPart: FC = () => {
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
icon={icon}
label={segment.doc.title}
- tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
- onClick={isFolder ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)}
+ tooltip={
+ isFolder
+ ? `Folder: ${segment.doc.title}`
+ : isConnector
+ ? `Connector account: ${segment.doc.title}`
+ : segment.doc.title
+ }
+ onClick={
+ isFolder || isConnector ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)
+ }
className="mx-0.5"
/>
);
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
index 503ca239c..ca90ba9b9 100644
--- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
@@ -1063,6 +1063,7 @@ function AuthenticatedDocumentsSidebarBase({
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
return sidebarDocs
.filter((doc) => {
+ if (doc.kind !== "doc") return false;
const fullDoc = treeDocMap.get(doc.id);
if (!fullDoc) return false;
const state = fullDoc.status?.state ?? "ready";
@@ -1124,7 +1125,7 @@ function AuthenticatedDocumentsSidebarBase({
try {
await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted");
- setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
+ setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== id));
return true;
} catch (e) {
console.error("Error deleting document:", e);
@@ -1953,7 +1954,7 @@ function AnonymousDocumentsSidebar({
onEditDocument={() => gate("edit documents")}
onDeleteDocument={async () => {
handleRemoveDoc();
- setSidebarDocs((prev) => prev.filter((d) => d.id !== -1));
+ setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1));
return true;
}}
onMoveDocument={() => gate("organize documents")}
diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx
index d0f7fb67c..f8a84c51b 100644
--- a/surfsense_web/components/new-chat/document-mention-picker.tsx
+++ b/surfsense_web/components/new-chat/document-mention-picker.tsx
@@ -2,21 +2,35 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
-import { Folder as FolderIcon } from "lucide-react";
+import {
+ BookOpen,
+ ChevronLeft,
+ ChevronRight,
+ Files,
+ Folder as FolderIcon,
+ Plug,
+} from "lucide-react";
import {
forwardRef,
useCallback,
useDeferredValue,
useEffect,
- useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
+import type * as React from "react";
import {
FOLDER_MENTION_DOCUMENT_TYPE,
type MentionedDocumentInfo,
} from "@/atoms/chat/mentioned-documents.atom";
+import { useAtomValue } from "jotai";
+import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
+import {
+ COMPOSIO_CONNECTORS,
+ OAUTH_CONNECTORS,
+} from "@/components/assistant-ui/connector-popup/constants/connector-constants";
+import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab";
import {
ComposerSuggestionGroup,
ComposerSuggestionGroupHeading,
@@ -26,18 +40,20 @@ import {
ComposerSuggestionSeparator,
ComposerSuggestionSkeleton,
} from "@/components/new-chat/composer-suggestion-popup";
+import {
+ type ComposerSuggestionNavigatorRef,
+ type ComposerSuggestionNode,
+ useComposerSuggestionNavigator,
+} from "@/components/new-chat/use-composer-suggestion-navigator";
import { Spinner } from "@/components/ui/spinner";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
+import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { queries } from "@/zero/queries";
-export interface DocumentMentionPickerRef {
- selectHighlighted: () => void;
- moveUp: () => void;
- moveDown: () => void;
-}
+export type DocumentMentionPickerRef = ComposerSuggestionNavigatorRef;
interface DocumentMentionPickerProps {
searchSpaceId: number;
@@ -51,34 +67,86 @@ const PAGE_SIZE = 20;
const MIN_SEARCH_LENGTH = 2;
const DEBOUNCE_MS = 100;
-/**
- * Custom debounce hook that delays value updates until user input stabilizes.
- * Preferred over throttling for search inputs as it reduces API request frequency
- * and prevents race conditions from stale responses overtaking recent ones.
- */
+type BrowseView =
+ | { kind: "root" }
+ | { kind: "surfsense-docs" }
+ | { kind: "files-folders" }
+ | { kind: "connectors" }
+ | { kind: "connector-type"; connectorType: string; title: string };
+
+type ResourceNodeValue =
+ | { kind: "view"; view: BrowseView }
+ | { kind: "mention"; mention: MentionedDocumentInfo };
+
function useDebounced(value: T, delay = DEBOUNCE_MS) {
const [debounced, setDebounced] = useState(value);
const timeoutRef = useRef | undefined>(undefined);
useEffect(() => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
-
- timeoutRef.current = setTimeout(() => {
- setDebounced(value);
- }, delay);
-
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setDebounced(value), delay);
return () => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [value, delay]);
return debounced;
}
+function titleForConnectorType(connectorType: string) {
+ const configured =
+ OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
+ COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
+ return (
+ configured?.title ||
+ connectorType
+ .replace(/_/g, " ")
+ .replace(/connector/gi, "")
+ .trim()
+ );
+}
+
+function makeDocMention(doc: Pick): MentionedDocumentInfo {
+ return {
+ id: doc.id,
+ title: doc.title,
+ document_type: doc.document_type,
+ kind: "doc",
+ };
+}
+
+function makeFolderMention(folder: { id: number; title: string }): MentionedDocumentInfo {
+ return {
+ id: folder.id,
+ title: folder.title,
+ document_type: FOLDER_MENTION_DOCUMENT_TYPE,
+ kind: "folder",
+ };
+}
+
+function makeConnectorMention(connector: SearchSourceConnector): MentionedDocumentInfo {
+ const accountName = getConnectorDisplayName(connector.name);
+ const connectorTitle = titleForConnectorType(connector.connector_type);
+ return {
+ id: connector.id,
+ title: `${connectorTitle}: ${accountName}`,
+ document_type: connector.connector_type,
+ kind: "connector",
+ connector_type: connector.connector_type,
+ account_name: accountName,
+ };
+}
+
+function mentionMatchesSearch(mention: MentionedDocumentInfo, searchLower: string) {
+ return [
+ mention.title,
+ mention.document_type,
+ mention.kind,
+ mention.kind === "connector" ? mention.connector_type : "",
+ mention.kind === "connector" ? mention.account_name : "",
+ ].some((value) => value.toLowerCase().includes(searchLower));
+}
+
export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef,
DocumentMentionPickerProps
@@ -86,18 +154,14 @@ export const DocumentMentionPicker = forwardRef<
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
ref
) {
- // Debounced search value to minimize API calls and prevent race conditions
const search = externalSearch;
const debouncedSearch = useDebounced(search, DEBOUNCE_MS);
- // Deferred snapshot of debouncedSearch — client-side filtering uses this so it
- // is treated as a non-urgent update, keeping the input responsive.
const deferredSearch = useDeferredValue(debouncedSearch);
- const [highlightedIndex, setHighlightedIndex] = useState(0);
- const itemRefs = useRef