feat(web): enhance chat context and mention handling with connector support

This commit is contained in:
Anish Sarkar 2026-05-26 21:11:53 +05:30
parent 701ae800b4
commit a41b16b73e
15 changed files with 773 additions and 449 deletions

View file

@ -64,6 +64,8 @@ class SurfSenseContextSchema:
search_space_id: int | None = None search_space_id: int | None = None
mentioned_document_ids: list[int] = field(default_factory=list) mentioned_document_ids: list[int] = field(default_factory=list)
mentioned_folder_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 file_operation_contract: FileOperationContractState | None = None
turn_id: str | None = None turn_id: str | None = None
request_id: str | None = None request_id: str | None = None

View file

@ -134,7 +134,7 @@ async def resolve_mentions(
kind = chip.kind kind = chip.kind
if kind == "folder": if kind == "folder":
chip_folder_ids.append(chip.id) chip_folder_ids.append(chip.id)
else: elif kind == "doc":
chip_doc_ids.append(chip.id) chip_doc_ids.append(chip.id)
chip_titles_by_id[(kind, chip.id)] = chip.title chip_titles_by_id[(kind, chip.id)] = chip.title

View file

@ -1771,6 +1771,11 @@ async def handle_new_chat(
if request.mentioned_documents if request.mentioned_documents
else None else None
) )
mentioned_connectors_payload = (
[doc.model_dump() for doc in request.mentioned_connectors]
if request.mentioned_connectors
else None
)
return StreamingResponse( return StreamingResponse(
stream_new_chat( stream_new_chat(
@ -1782,6 +1787,8 @@ async def handle_new_chat(
mentioned_document_ids=request.mentioned_document_ids, mentioned_document_ids=request.mentioned_document_ids,
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
mentioned_folder_ids=request.mentioned_folder_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, mentioned_documents=mentioned_documents_payload,
needs_history_bootstrap=thread.needs_history_bootstrap, needs_history_bootstrap=thread.needs_history_bootstrap,
thread_visibility=thread.visibility, thread_visibility=thread.visibility,
@ -2258,6 +2265,11 @@ async def regenerate_response(
if request.mentioned_documents if request.mentioned_documents
else None else None
) )
mentioned_connectors_payload = (
[doc.model_dump() for doc in request.mentioned_connectors]
if request.mentioned_connectors
else None
)
try: try:
async for chunk in stream_new_chat( async for chunk in stream_new_chat(
user_query=str(user_query_to_use), user_query=str(user_query_to_use),
@ -2268,6 +2280,8 @@ async def regenerate_response(
mentioned_document_ids=request.mentioned_document_ids, mentioned_document_ids=request.mentioned_document_ids,
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
mentioned_folder_ids=request.mentioned_folder_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, mentioned_documents=mentioned_documents_payload,
checkpoint_id=target_checkpoint_id, checkpoint_id=target_checkpoint_id,
needs_history_bootstrap=thread.needs_history_bootstrap, needs_history_bootstrap=thread.needs_history_bootstrap,

View file

@ -218,17 +218,20 @@ class MentionedDocumentInfo(BaseModel):
id: int id: int
title: str = Field(..., min_length=1, max_length=500) title: str = Field(..., min_length=1, max_length=500)
document_type: str = Field(..., min_length=1, max_length=100) document_type: str = Field(..., min_length=1, max_length=100)
kind: Literal["doc", "folder"] = Field( kind: Literal["doc", "folder", "connector"] = Field(
default="doc", default="doc",
description=( description=(
"Discriminator for the chip's referent: ``doc`` is a " "Discriminator for the chip's referent: ``doc`` is a "
"knowledge-base ``Document`` row, ``folder`` 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 " "``document_type='FOLDER'`` to keep the frontend dedup key "
"``(kind:document_type:id)`` from colliding doc and folder " "``(kind:document_type:id)`` from colliding doc and folder "
"ids that happen to share an integer value." "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): class NewChatRequest(BaseModel):
@ -266,6 +269,18 @@ class NewChatRequest(BaseModel):
"a mentioned-documents part." "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 = ( disabled_tools: list[str] | None = (
None # Optional list of tool names the user has disabled from the UI 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." "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 disabled_tools: list[str] | None = None
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web" client_platform: Literal["web", "desktop"] = "web"

View file

@ -137,15 +137,19 @@ def _build_user_content(
if doc_id is None or title is None or document_type is None: if doc_id is None or title is None or document_type is None:
continue continue
kind_raw = doc.get("kind", "doc") kind_raw = doc.get("kind", "doc")
kind = kind_raw if kind_raw in ("doc", "folder") else "doc" kind = kind_raw if kind_raw in ("doc", "folder", "connector") else "doc"
normalized.append( item = {
{ "id": doc_id,
"id": doc_id, "title": str(title),
"title": str(title), "document_type": str(document_type),
"document_type": str(document_type), "kind": kind,
"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: if normalized:
parts.append({"type": "mentioned-documents", "documents": normalized}) parts.append({"type": "mentioned-documents", "documents": normalized})
return parts return parts

View file

@ -839,6 +839,8 @@ async def stream_new_chat(
mentioned_document_ids: list[int] | None = None, mentioned_document_ids: list[int] | None = None,
mentioned_surfsense_doc_ids: list[int] | None = None, mentioned_surfsense_doc_ids: list[int] | None = None,
mentioned_folder_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, mentioned_documents: list[dict[str, Any]] | None = None,
checkpoint_id: str | None = None, checkpoint_id: str | None = None,
needs_history_bootstrap: bool = False, needs_history_bootstrap: bool = False,
@ -1385,6 +1387,32 @@ async def stream_new_chat(
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs) 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(
"<mentioned_connectors>\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</mentioned_connectors>"
)
# Surface report IDs prominently so the LLM doesn't have to # Surface report IDs prominently so the LLM doesn't have to
# retrieve them from old tool responses in conversation history. # retrieve them from old tool responses in conversation history.
if recent_reports: if recent_reports:
@ -1778,6 +1806,8 @@ async def stream_new_chat(
mentioned_folder_ids=list( mentioned_folder_ids=list(
accepted_folder_ids or mentioned_folder_ids or [] 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, request_id=request_id,
turn_id=stream_result.turn_id, turn_id=stream_result.turn_id,
) )

View file

@ -208,9 +208,11 @@ const MentionedDocumentInfoSchema = z.object({
title: z.string(), title: z.string(),
document_type: z.string(), document_type: z.string(),
kind: z kind: z
.union([z.literal("doc"), z.literal("folder")]) .union([z.literal("doc"), z.literal("folder"), z.literal("connector")])
.optional() .optional()
.default("doc"), .default("doc"),
connector_type: z.string().optional(),
account_name: z.string().optional(),
}); });
const MentionedDocumentsPartSchema = z.object({ const MentionedDocumentsPartSchema = z.object({
@ -227,7 +229,32 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
for (const part of content) { for (const part of content) {
const result = MentionedDocumentsPartSchema.safeParse(part); const result = MentionedDocumentsPartSchema.safeParse(part);
if (result.success) { if (result.success) {
return result.data.documents; return result.data.documents.map<MentionedDocumentInfo>((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: hasMentionedDocuments:
mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
mentionedDocumentIds.document_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, messageLength: userQuery.length,
}); });
@ -940,12 +968,7 @@ export default function NewChatPage() {
const key = `${doc.kind}:${doc.document_type}:${doc.id}`; const key = `${doc.kind}:${doc.document_type}:${doc.id}`;
if (seenDocKeys.has(key)) continue; if (seenDocKeys.has(key)) continue;
seenDocKeys.add(key); seenDocKeys.add(key);
allMentionedDocs.push({ allMentionedDocs.push(doc);
id: doc.id,
title: doc.title,
document_type: doc.document_type,
kind: doc.kind,
});
} }
if (allMentionedDocs.length > 0) { if (allMentionedDocs.length > 0) {
@ -1008,9 +1031,10 @@ export default function NewChatPage() {
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0; const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0; const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0; const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
const hasConnectorIds = mentionedDocumentIds.connector_ids.length > 0;
// Clear mentioned documents after capturing them // Clear mentioned documents after capturing them
if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds) { if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds || hasConnectorIds) {
setMentionedDocuments([]); setMentionedDocuments([]);
} }
@ -1036,20 +1060,16 @@ export default function NewChatPage() {
? mentionedDocumentIds.surfsense_doc_ids ? mentionedDocumentIds.surfsense_doc_ids
: undefined, : undefined,
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_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 // Full mention metadata (docs + folders, with
// ``kind`` discriminator) so the BE can embed a // ``kind`` discriminator) so the BE can embed a
// ``mentioned-documents`` ContentPart on the // ``mentioned-documents`` ContentPart on the
// persisted user message (replaces the old FE-side // persisted user message (replaces the old FE-side
// injection in ``persistUserTurn``). // injection in ``persistUserTurn``).
mentioned_documents: mentioned_documents: allMentionedDocs.length > 0 ? allMentionedDocs : undefined,
allMentionedDocs.length > 0
? allMentionedDocs.map((d) => ({
id: d.id,
title: d.title,
document_type: d.document_type,
kind: d.kind,
}))
: undefined,
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined, disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
...(userImages.length > 0 ? { user_images: userImages } : {}), ...(userImages.length > 0 ? { user_images: userImages } : {}),
}), }),
@ -1945,6 +1965,7 @@ export default function NewChatPage() {
const regenerateFolderIds = sourceMentionedDocs const regenerateFolderIds = sourceMentionedDocs
.filter((d) => d.kind === "folder") .filter((d) => d.kind === "folder")
.map((d) => d.id); .map((d) => d.id);
const regenerateConnectors = sourceMentionedDocs.filter((d) => d.kind === "connector");
const requestBody: Record<string, unknown> = { const requestBody: Record<string, unknown> = {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
@ -1957,19 +1978,16 @@ export default function NewChatPage() {
mentioned_surfsense_doc_ids: mentioned_surfsense_doc_ids:
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined, regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : 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 // Full mention metadata for the regenerate-specific
// source list. Only meaningful for edit (the BE only // source list. Only meaningful for edit (the BE only
// re-persists a user row when ``user_query`` is set); // re-persists a user row when ``user_query`` is set);
// reload reuses the original turn's mentioned_documents. // reload reuses the original turn's mentioned_documents.
mentioned_documents: mentioned_documents:
sourceMentionedDocs.length > 0 sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
? sourceMentionedDocs.map((d) => ({
id: d.id,
title: d.title,
document_type: d.document_type,
kind: d.kind,
}))
: undefined,
}; };
if (isEdit) { if (isEdit) {
requestBody.user_images = editExtras?.userImages ?? []; requestBody.user_images = editExtras?.userImages ?? [];

View file

@ -13,18 +13,31 @@ export const FOLDER_MENTION_DOCUMENT_TYPE = "FOLDER";
/** /**
* Display metadata for a single ``@``-mention chip. * Display metadata for a single ``@``-mention chip.
* *
* The ``kind`` discriminator identifies whether the chip is a * Historical name is retained because this atom is already wired into
* knowledge-base document or a knowledge-base folder. Folders carry * chat persistence and sidebar selection. The shape is now the selected
* the sentinel ``document_type === FOLDER_MENTION_DOCUMENT_TYPE`` so * composer context, not only documents.
* the editor, picker, and persisted ``mentioned-documents`` content
* part all stay aligned with the backend Pydantic schema.
*/ */
export interface MentionedDocumentInfo { export type MentionedDocumentInfo =
id: number; | {
title: string; id: number;
document_type: string; title: string;
kind: "doc" | "folder"; 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 * Backwards-compatible doc-only chip shape for legacy callers that
@ -44,7 +57,10 @@ type LegacyDocMention = Pick<Document, "id" | "title" | "document_type">;
export function toMentionedDocumentInfo( export function toMentionedDocumentInfo(
input: LegacyDocMention | MentionedDocumentInfo input: LegacyDocMention | MentionedDocumentInfo
): 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 input;
} }
return { return {
@ -93,12 +109,22 @@ export const mentionedDocumentIdsAtom = atom((get) => {
}); });
const docs = deduped.filter((m) => m.kind === "doc"); const docs = deduped.filter((m) => m.kind === "doc");
const folders = deduped.filter((m) => m.kind === "folder"); const folders = deduped.filter((m) => m.kind === "folder");
const connectors = deduped.filter((m) => m.kind === "connector");
return { return {
surfsense_doc_ids: docs surfsense_doc_ids: docs
.filter((doc) => doc.document_type === "SURFSENSE_DOCS") .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id), .map((doc) => doc.id),
document_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), 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,
})),
}; };
}); });

View file

@ -1,6 +1,6 @@
"use client"; "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 { NodeEntry, TElement } from "platejs";
import type { PlateElementProps } from "platejs/react"; import type { PlateElementProps } from "platejs/react";
import { import {
@ -27,13 +27,15 @@ import type { Document } from "@/contracts/types/document.types";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type MentionKind = "doc" | "folder"; export type MentionKind = "doc" | "folder" | "connector";
export interface MentionedDocument { export interface MentionedDocument {
id: number; id: number;
title: string; title: string;
document_type?: string; document_type?: string;
kind: MentionKind; kind: MentionKind;
connector_type?: string;
account_name?: string;
} }
/** /**
@ -46,6 +48,8 @@ export type MentionChipInput = {
title: string; title: string;
document_type?: string; document_type?: string;
kind?: MentionKind; kind?: MentionKind;
connector_type?: string;
account_name?: string;
}; };
export type SuggestionAnchorRect = { export type SuggestionAnchorRect = {
@ -107,6 +111,8 @@ type MentionElementNode = {
document_type?: string; document_type?: string;
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */ /** Discriminator; defaults to ``"doc"`` for legacy nodes. */
kind?: MentionKind; kind?: MentionKind;
connector_type?: string;
account_name?: string;
statusLabel?: string | null; statusLabel?: string | null;
statusKind?: MentionStatusKind; statusKind?: MentionStatusKind;
children: [{ text: "" }]; children: [{ text: "" }];
@ -146,6 +152,7 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
: "text-amber-700"; : "text-amber-700";
const isFolder = element.kind === "folder"; const isFolder = element.kind === "folder";
const isConnector = element.kind === "connector";
const ctx = useContext(MentionEditorContext); const ctx = useContext(MentionEditorContext);
return ( return (
@ -156,6 +163,10 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0"> <span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
{isFolder ? ( {isFolder ? (
<FolderIcon className="h-3 w-3" /> <FolderIcon className="h-3 w-3" />
) : isConnector ? (
getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? (
<PlugIcon className="h-3 w-3" />
)
) : ( ) : (
getConnectorIcon(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, title: node.title,
document_type: node.document_type, document_type: node.document_type,
kind, kind,
connector_type: node.connector_type,
account_name: node.account_name,
}; };
map.set(getMentionDocKey(doc), doc); map.set(getMentionDocKey(doc), doc);
} }
@ -444,13 +457,20 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const removeTriggerText = options?.removeTriggerText ?? true; const removeTriggerText = options?.removeTriggerText ?? true;
const kind: MentionKind = mention.kind ?? "doc"; const kind: MentionKind = mention.kind ?? "doc";
const document_type = const document_type =
mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined); mention.document_type ??
(kind === "folder"
? FOLDER_MENTION_DOCUMENT_TYPE
: kind === "connector"
? mention.connector_type
: undefined);
const mentionNode: MentionElementNode = { const mentionNode: MentionElementNode = {
type: MENTION_TYPE, type: MENTION_TYPE,
id: mention.id, id: mention.id,
title: mention.title, title: mention.title,
document_type, document_type,
kind, kind,
connector_type: mention.connector_type,
account_name: mention.account_name,
children: [{ text: "" }], children: [{ text: "" }],
}; };

View file

@ -36,6 +36,7 @@ import {
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { import {
FOLDER_MENTION_DOCUMENT_TYPE,
type MentionedDocumentInfo, type MentionedDocumentInfo,
mentionedDocumentsAtom, mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
@ -71,7 +72,7 @@ import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer
import { import {
DocumentMentionPicker, DocumentMentionPicker,
type DocumentMentionPickerRef, 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 { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -542,15 +543,36 @@ const Composer: FC = () => {
return prev; return prev;
} }
} }
return docs.map<MentionedDocumentInfo>((d) => ({ return docs.map<MentionedDocumentInfo>((d) => {
id: d.id, const documentType = d.document_type ?? "UNKNOWN";
title: d.title, if (d.kind === "connector") {
// Atom requires a string; ``"UNKNOWN"`` matches the return {
// sentinel ``getMentionDocKey`` and the editor's id: d.id,
// match predicates use. title: d.title,
document_type: d.document_type ?? "UNKNOWN", document_type: documentType,
kind: d.kind, 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] [aui, setMentionedDocuments]
@ -700,6 +722,9 @@ const Composer: FC = () => {
} }
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
if (documentPickerRef.current?.goBack()) {
return;
}
setShowDocumentPopover(false); setShowDocumentPopover(false);
setMentionQuery(""); setMentionQuery("");
setSuggestionAnchorPoint(null); setSuggestionAnchorPoint(null);

View file

@ -6,7 +6,7 @@ import {
useMessagePartText, useMessagePartText,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai"; 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 Image from "next/image";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
@ -100,8 +100,13 @@ const UserTextPart: FC = () => {
return <span key={`txt-${segment.start}`}>{segment.value}</span>; return <span key={`txt-${segment.start}`}>{segment.value}</span>;
} }
const isFolder = segment.doc.kind === "folder"; const isFolder = segment.doc.kind === "folder";
const isConnector = segment.doc.kind === "connector";
const icon = isFolder ? ( const icon = isFolder ? (
<FolderIcon className="size-3.5" /> <FolderIcon className="size-3.5" />
) : isConnector ? (
getConnectorIcon(segment.doc.connector_type ?? segment.doc.document_type, "size-3.5") ?? (
<Plug className="size-3.5" />
)
) : ( ) : (
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "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}`} key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
icon={icon} icon={icon}
label={segment.doc.title} label={segment.doc.title}
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title} tooltip={
onClick={isFolder ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)} 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" className="mx-0.5"
/> />
); );

View file

@ -1063,6 +1063,7 @@ function AuthenticatedDocumentsSidebarBase({
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d])); const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
return sidebarDocs return sidebarDocs
.filter((doc) => { .filter((doc) => {
if (doc.kind !== "doc") return false;
const fullDoc = treeDocMap.get(doc.id); const fullDoc = treeDocMap.get(doc.id);
if (!fullDoc) return false; if (!fullDoc) return false;
const state = fullDoc.status?.state ?? "ready"; const state = fullDoc.status?.state ?? "ready";
@ -1124,7 +1125,7 @@ function AuthenticatedDocumentsSidebarBase({
try { try {
await deleteDocumentMutation({ id }); await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted"); 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; return true;
} catch (e) { } catch (e) {
console.error("Error deleting document:", e); console.error("Error deleting document:", e);
@ -1953,7 +1954,7 @@ function AnonymousDocumentsSidebar({
onEditDocument={() => gate("edit documents")} onEditDocument={() => gate("edit documents")}
onDeleteDocument={async () => { onDeleteDocument={async () => {
handleRemoveDoc(); handleRemoveDoc();
setSidebarDocs((prev) => prev.filter((d) => d.id !== -1)); setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1));
return true; return true;
}} }}
onMoveDocument={() => gate("organize documents")} onMoveDocument={() => gate("organize documents")}

View file

@ -2,21 +2,35 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react"; import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; 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 { import {
forwardRef, forwardRef,
useCallback, useCallback,
useDeferredValue, useDeferredValue,
useEffect, useEffect,
useImperativeHandle,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type * as React from "react";
import { import {
FOLDER_MENTION_DOCUMENT_TYPE, FOLDER_MENTION_DOCUMENT_TYPE,
type MentionedDocumentInfo, type MentionedDocumentInfo,
} from "@/atoms/chat/mentioned-documents.atom"; } 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 { import {
ComposerSuggestionGroup, ComposerSuggestionGroup,
ComposerSuggestionGroupHeading, ComposerSuggestionGroupHeading,
@ -26,18 +40,20 @@ import {
ComposerSuggestionSeparator, ComposerSuggestionSeparator,
ComposerSuggestionSkeleton, ComposerSuggestionSkeleton,
} from "@/components/new-chat/composer-suggestion-popup"; } 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 { Spinner } from "@/components/ui/spinner";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types"; import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { queries } from "@/zero/queries"; import { queries } from "@/zero/queries";
export interface DocumentMentionPickerRef { export type DocumentMentionPickerRef = ComposerSuggestionNavigatorRef;
selectHighlighted: () => void;
moveUp: () => void;
moveDown: () => void;
}
interface DocumentMentionPickerProps { interface DocumentMentionPickerProps {
searchSpaceId: number; searchSpaceId: number;
@ -51,34 +67,86 @@ const PAGE_SIZE = 20;
const MIN_SEARCH_LENGTH = 2; const MIN_SEARCH_LENGTH = 2;
const DEBOUNCE_MS = 100; const DEBOUNCE_MS = 100;
/** type BrowseView =
* Custom debounce hook that delays value updates until user input stabilizes. | { kind: "root" }
* Preferred over throttling for search inputs as it reduces API request frequency | { kind: "surfsense-docs" }
* and prevents race conditions from stale responses overtaking recent ones. | { kind: "files-folders" }
*/ | { kind: "connectors" }
| { kind: "connector-type"; connectorType: string; title: string };
type ResourceNodeValue =
| { kind: "view"; view: BrowseView }
| { kind: "mention"; mention: MentionedDocumentInfo };
function useDebounced<T>(value: T, delay = DEBOUNCE_MS) { function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (timeoutRef.current) { if (timeoutRef.current) clearTimeout(timeoutRef.current);
clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setDebounced(value), delay);
}
timeoutRef.current = setTimeout(() => {
setDebounced(value);
}, delay);
return () => { return () => {
if (timeoutRef.current) { if (timeoutRef.current) clearTimeout(timeoutRef.current);
clearTimeout(timeoutRef.current);
}
}; };
}, [value, delay]); }, [value, delay]);
return debounced; 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<Document, "id" | "title" | "document_type">): 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< export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef, DocumentMentionPickerRef,
DocumentMentionPickerProps DocumentMentionPickerProps
@ -86,18 +154,14 @@ export const DocumentMentionPicker = forwardRef<
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" }, { searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
ref ref
) { ) {
// Debounced search value to minimize API calls and prevent race conditions
const search = externalSearch; const search = externalSearch;
const debouncedSearch = useDebounced(search, DEBOUNCE_MS); 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 deferredSearch = useDeferredValue(debouncedSearch);
const [highlightedIndex, setHighlightedIndex] = useState(0); const hasSearch = debouncedSearch.trim().length > 0;
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); const isSearchValid = debouncedSearch.trim().length >= MIN_SEARCH_LENGTH;
const scrollContainerRef = useRef<HTMLDivElement>(null); const isSingleCharSearch = debouncedSearch.trim().length === 1;
const shouldScrollRef = useRef(false); // Keyboard navigation scroll flag const [view, setView] = useState<BrowseView>({ kind: "root" });
// Pagination state for infinite scroll
const [accumulatedDocuments, setAccumulatedDocuments] = useState< const [accumulatedDocuments, setAccumulatedDocuments] = useState<
Pick<Document, "id" | "title" | "document_type">[] Pick<Document, "id" | "title" | "document_type">[]
>([]); >([]);
@ -105,32 +169,26 @@ export const DocumentMentionPicker = forwardRef<
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
// Folders for this search space — pulled from Zero so the picker
// stays consistent with the documents sidebar (same source of
// truth, automatic updates on rename/delete).
const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId })); const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId }));
const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom);
const paginationScopeKey = useMemo(
() => `${searchSpaceId}:${debouncedSearch}`,
[searchSpaceId, debouncedSearch]
);
const previousPaginationScopeKeyRef = useRef<string | null>(null);
/** // Reset pagination state when the active search scope changes.
* 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
useEffect(() => { useEffect(() => {
if (previousPaginationScopeKeyRef.current === paginationScopeKey) return;
previousPaginationScopeKeyRef.current = paginationScopeKey;
setCurrentPage(0); setCurrentPage(0);
setHasMore(false); setHasMore(false);
setHighlightedIndex(0); }, [paginationScopeKey]);
}, [debouncedSearch, searchSpaceId]);
useEffect(() => {
if (hasSearch) setView({ kind: "root" });
}, [hasSearch]);
// Query parameters for lightweight title search endpoint
const titleSearchParams = useMemo( const titleSearchParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
@ -146,77 +204,59 @@ export const DocumentMentionPicker = forwardRef<
page: 0, page: 0,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}; };
if (isSearchValid) { if (isSearchValid) params.title = debouncedSearch.trim();
params.title = debouncedSearch.trim();
}
return params; return params;
}, [debouncedSearch, isSearchValid]); }, [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({ const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({
queryKey: ["document-titles", titleSearchParams], queryKey: ["document-titles", titleSearchParams],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal), documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal),
staleTime: 60 * 1000, staleTime: 60 * 1000,
enabled: !!searchSpaceId && currentPage === 0 && (!shouldSearch || isSearchValid), enabled: !!searchSpaceId && currentPage === 0 && (!hasSearch || isSearchValid),
placeholderData: keepPreviousData, 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({ const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid], queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal), documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
staleTime: 3 * 60 * 1000, staleTime: 3 * 60 * 1000,
enabled: !shouldSearch || isSearchValid, enabled: !hasSearch || isSearchValid,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
// Post-fetch filter to eliminate false positives from backend fuzzy matching
const filterBySearchTerm = useCallback( const filterBySearchTerm = useCallback(
(docs: Pick<Document, "id" | "title" | "document_type">[]) => { (docs: Pick<Document, "id" | "title" | "document_type">[]) => {
if (!isSearchValid) return docs; // No filtering when not searching if (!isSearchValid) return docs;
const searchLower = debouncedSearch.trim().toLowerCase(); const searchLower = debouncedSearch.trim().toLowerCase();
return docs.filter((doc) => doc.title.toLowerCase().includes(searchLower)); return docs.filter((doc) => doc.title.toLowerCase().includes(searchLower));
}, },
[debouncedSearch, isSearchValid] [debouncedSearch, isSearchValid]
); );
// Combine and update document list when first page data arrives
useEffect(() => { useEffect(() => {
if (currentPage === 0) { if (currentPage !== 0) return;
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = []; const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
// SurfSense docs displayed first in the list if (surfsenseDocs?.items) {
if (surfsenseDocs?.items) { for (const doc of surfsenseDocs.items) {
for (const doc of surfsenseDocs.items) { combinedDocs.push({
combinedDocs.push({ id: doc.id,
id: doc.id, title: doc.title,
title: doc.title, document_type: "SURFSENSE_DOCS",
document_type: "SURFSENSE_DOCS", });
});
}
} }
if (titleSearchResults?.items) {
combinedDocs.push(...titleSearchResults.items);
setHasMore(titleSearchResults.has_more);
}
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
} }
if (titleSearchResults?.items) {
combinedDocs.push(...titleSearchResults.items);
setHasMore(titleSearchResults.has_more);
}
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]); }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
// Load next page for infinite scroll pagination
const loadNextPage = useCallback(async () => { const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return; if (isLoadingMore || !hasMore) return;
@ -230,9 +270,9 @@ export const DocumentMentionPicker = forwardRef<
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
...(isSearchValid ? { title: debouncedSearch.trim() } : {}), ...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
}; };
const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles( const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles({
{ queryParams } queryParams,
); });
setAccumulatedDocuments((prev) => [...prev, ...response.items]); setAccumulatedDocuments((prev) => [...prev, ...response.items]);
setHasMore(response.has_more); setHasMore(response.has_more);
@ -244,41 +284,12 @@ export const DocumentMentionPicker = forwardRef<
} }
}, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId, isSearchValid]); }, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId, isSearchValid]);
// Trigger pagination when user scrolls near the bottom (50px threshold) const actualDocuments = useMemo(() => {
const handleScroll = useCallback( if (!isSingleCharSearch) return accumulatedDocuments;
(e: React.UIEvent<HTMLDivElement>) => {
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 searchLower = deferredSearch.trim().toLowerCase(); const searchLower = deferredSearch.trim().toLowerCase();
return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower)); 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( const surfsenseDocsList = useMemo(
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"), () => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
[actualDocuments] [actualDocuments]
@ -287,47 +298,25 @@ export const DocumentMentionPicker = forwardRef<
() => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"), () => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"),
[actualDocuments] [actualDocuments]
); );
const folderMentions = useMemo(() => {
// Folder mention candidates filtered by the current search term. const all = (zeroFolders ?? []).map((f) => makeFolderMention({ id: f.id, title: f.name }));
// Single-char and server-search both use the same client filter if (!hasSearch) return all;
// — 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 needle = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase(); const needle = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase();
if (!needle) return all; if (!needle) return all;
return all.filter((f) => f.title.toLowerCase().includes(needle)); return all.filter((f) => f.title.toLowerCase().includes(needle));
}, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, shouldSearch]); }, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, hasSearch]);
const connectorMentions = useMemo(
() => connectors.filter((c) => c.is_active).map(makeConnectorMention),
[connectors]
);
// Doc-shape entries reuse their ``document_type`` discriminator;
// folder entries lift the existing kind-aware key so the same
// matchers used by the chip atom apply unchanged.
const selectedKeys = useMemo( const selectedKeys = useMemo(
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))), () => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
[initialSelectedDocuments] [initialSelectedDocuments]
); );
// Combined navigation order: SurfSense docs -> User docs -> Folders. const selectMention = useCallback(
// Mirrors the on-screen ordering so keyboard arrows match what the
// user sees.
const selectableMentions = useMemo<MentionedDocumentInfo[]>(() => {
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(
(mention: MentionedDocumentInfo) => { (mention: MentionedDocumentInfo) => {
onSelectionChange([...initialSelectedDocuments, mention]); onSelectionChange([...initialSelectedDocuments, mention]);
onDone(); onDone();
@ -335,258 +324,303 @@ export const DocumentMentionPicker = forwardRef<
[initialSelectedDocuments, onSelectionChange, onDone] [initialSelectedDocuments, onSelectionChange, onDone]
); );
// Auto-scroll highlighted item into view (keyboard navigation only, not mouse hover) const rootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(
useEffect(() => { () => [
if (!shouldScrollRef.current) { {
return; id: "surfsense-docs",
} label: "SurfSense Docs",
shouldScrollRef.current = false; subtitle: "Browse product documentation",
icon: <BookOpen className="size-4" />,
const rafId = requestAnimationFrame(() => { type: "branch",
const item = itemRefs.current.get(highlightedIndex); value: { kind: "view", view: { kind: "surfsense-docs" } },
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; id: "files-folders",
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1)); label: "Files & Folders",
subtitle: "Browse your knowledge base",
icon: <Files className="size-4" />,
type: "branch",
value: { kind: "view", view: { kind: "files-folders" } },
}, },
moveDown: () => { {
shouldScrollRef.current = true; id: "connectors",
setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0)); label: "Connectors",
subtitle: connectors.length
? "Choose the exact account for tool use"
: "No connected accounts yet",
icon: <Plug className="size-4" />,
type: "branch",
disabled: connectors.length === 0,
value: { kind: "view", view: { kind: "connectors" } },
}, },
}), ],
[selectableMentions, highlightedIndex, handleSelectMention] [connectors.length]
); );
// Keyboard navigation handler for arrow keys, Enter, and Escape const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
const handleKeyDown = useCallback( const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase();
(e: React.KeyboardEvent) => { const docNodes = actualDocuments.map((doc) => {
if (selectableMentions.length === 0) return; 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: <FolderIcon className="size-4" />,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
}));
const connectorNodes = connectorMentions
.filter((mention) => !searchLower || mentionMatchesSearch(mention, searchLower))
.map((mention) => ({
id: getMentionDocKey(mention),
label: mention.title,
subtitle: "Connector account",
icon: getConnectorIcon(mention.document_type, "size-4") ?? <Plug className="size-4" />,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
}));
switch (e.key) { return [...docNodes, ...folderNodes, ...connectorNodes];
case "ArrowDown": }, [
e.preventDefault(); actualDocuments,
shouldScrollRef.current = true; connectorMentions,
setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0)); debouncedSearch,
break; deferredSearch,
case "ArrowUp": folderMentions,
e.preventDefault(); isSingleCharSearch,
shouldScrollRef.current = true; selectedKeys,
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1)); ]);
break;
case "Enter": const connectorTypeEntries = useMemo(() => {
e.preventDefault(); const byType = new Map<string, SearchSourceConnector[]>();
if (selectableMentions[highlightedIndex]) { for (const connector of connectors.filter((c) => c.is_active)) {
handleSelectMention(selectableMentions[highlightedIndex]); const list = byType.get(connector.connector_type) ?? [];
} list.push(connector);
break; byType.set(connector.connector_type, list);
case "Escape": }
e.preventDefault(); return Array.from(byType.entries()).sort(([a], [b]) =>
onDone(); titleForConnectorType(a).localeCompare(titleForConnectorType(b))
break; );
}, [connectors]);
const browseNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
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: <FolderIcon className="size-4" />,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
}));
const docs = userDocsList.map((doc) => {
const mention = makeDocMention(doc);
return {
id: getMentionDocKey(mention),
label: doc.title,
icon: getConnectorIcon(doc.document_type, "size-4"),
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
};
});
return [...folders, ...docs];
}
if (view.kind === "connectors") {
return connectorTypeEntries.map(([connectorType, typeConnectors]) => ({
id: `connector-type:${connectorType}`,
label: titleForConnectorType(connectorType),
subtitle: `${typeConnectors.length} ${typeConnectors.length === 1 ? "account" : "accounts"}`,
icon: getConnectorIcon(connectorType, "size-4") ?? <Plug className="size-4" />,
type: "branch" as const,
value: {
kind: "view" as const,
view: {
kind: "connector-type" as const,
connectorType,
title: titleForConnectorType(connectorType),
},
},
}));
}
return connectors
.filter((connector) => connector.is_active && connector.connector_type === view.connectorType)
.map((connector) => {
const mention = makeConnectorMention(connector);
return {
id: getMentionDocKey(mention),
label: getConnectorDisplayName(connector.name),
subtitle: `${view.title} account`,
icon: getConnectorIcon(connector.connector_type, "size-4") ?? <Plug className="size-4" />,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
};
});
}, [
connectors,
connectorTypeEntries,
folderMentions,
rootNodes,
selectedKeys,
surfsenseDocsList,
userDocsList,
view,
]);
const visibleNodes = hasSearch ? searchNodes : browseNodes;
const handleNodeSelect = useCallback(
(node: ComposerSuggestionNode<ResourceNodeValue>) => {
const value = node.value;
if (!value) return;
if (value.kind === "view") {
setView(value.view);
return;
}
selectMention(value.mention);
},
[selectMention]
);
const handleBack = useCallback(() => {
if (hasSearch || view.kind === "root") return false;
if (view.kind === "connector-type") {
setView({ kind: "connectors" });
return true;
}
setView({ kind: "root" });
return true;
}, [hasSearch, view]);
const navigator = useComposerSuggestionNavigator({
nodes: visibleNodes,
onSelect: handleNodeSelect,
onBack: handleBack,
ref,
});
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (view.kind === "connectors" || view.kind === "connector-type") return;
const target = e.currentTarget;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < 50 && hasMore && !isLoadingMore) {
loadNextPage();
} }
}, },
[selectableMentions, highlightedIndex, handleSelectMention, onDone] [hasMore, isLoadingMore, loadNextPage, view.kind]
); );
const actualLoading =
(isTitleSearchLoading || isSurfsenseDocsLoading || isConnectorsLoading) &&
!isSingleCharSearch &&
visibleNodes.length === 0 &&
(view.kind === "root" || hasSearch);
const title =
hasSearch || view.kind === "root"
? null
: view.kind === "surfsense-docs"
? "SurfSense Docs"
: view.kind === "files-folders"
? "Files & Folders"
: view.kind === "connectors"
? "Connectors"
: view.title;
return ( return (
<ComposerSuggestionList <ComposerSuggestionList
ref={scrollContainerRef} ref={navigator.scrollContainerRef}
onKeyDown={handleKeyDown}
onScroll={handleScroll} onScroll={handleScroll}
role="listbox" role="listbox"
tabIndex={-1} tabIndex={-1}
> >
{actualLoading ? ( {actualLoading ? (
<ComposerSuggestionSkeleton /> <ComposerSuggestionSkeleton />
) : actualDocuments.length > 0 || folderMentions.length > 0 ? ( ) : (
<ComposerSuggestionGroup> <ComposerSuggestionGroup>
{/* SurfSense Documentation */} {title ? (
{surfsenseDocsList.length > 0 && (
<> <>
<ComposerSuggestionGroupHeading>SurfSense Docs</ComposerSuggestionGroupHeading> <ComposerSuggestionItem
{surfsenseDocsList.map((doc) => { icon={<ChevronLeft className="size-4" />}
const mention: MentionedDocumentInfo = { muted
id: doc.id, onClick={handleBack}
title: doc.title, >
document_type: doc.document_type, <span className="flex-1 truncate text-sm">{title}</span>
kind: "doc", </ComposerSuggestionItem>
}; <ComposerSuggestionSeparator />
const docKey = getMentionDocKey(mention);
const isAlreadySelected = selectedKeys.has(docKey);
const selectableIndex = selectableMentions.findIndex(
(m) => getMentionDocKey(m) === docKey
);
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
return (
<ComposerSuggestionItem
key={docKey}
ref={(el) => {
if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el);
else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex);
}}
icon={getConnectorIcon(doc.document_type)}
selected={isHighlighted}
disabled={isAlreadySelected}
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
}
}}
>
<span className="flex-1 truncate text-sm" title={doc.title}>
{doc.title}
</span>
</ComposerSuggestionItem>
);
})}
</> </>
) : null}
{visibleNodes.length > 0 ? (
<>
{hasSearch ? (
<ComposerSuggestionGroupHeading>Suggested Context</ComposerSuggestionGroupHeading>
) : null}
{visibleNodes.map((node, index) => (
<ComposerSuggestionItem
key={node.id}
ref={navigator.getItemRef(index)}
icon={node.icon}
selected={index === navigator.highlightedIndex}
disabled={node.disabled}
onClick={() => !node.disabled && handleNodeSelect(node)}
onMouseEnter={() => navigator.setHighlightedIndex(index)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm" title={node.label}>
{node.label}
</span>
{node.subtitle ? (
<span className="block truncate text-[11px] text-muted-foreground">
{node.subtitle}
</span>
) : null}
</span>
{node.type === "branch" ? (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
) : null}
</ComposerSuggestionItem>
))}
</>
) : (
<ComposerSuggestionMessage>
{hasSearch ? "No matching context" : "No items available"}
</ComposerSuggestionMessage>
)} )}
{/* User Documents */}
{userDocsList.length > 0 && (
<>
{surfsenseDocsList.length > 0 && <ComposerSuggestionSeparator className="my-4" />}
<ComposerSuggestionGroupHeading>Your Documents</ComposerSuggestionGroupHeading>
{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 (
<ComposerSuggestionItem
key={docKey}
ref={(el) => {
if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el);
else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex);
}}
icon={getConnectorIcon(doc.document_type)}
selected={isHighlighted}
disabled={isAlreadySelected}
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
}
}}
>
<span className="flex-1 truncate text-sm" title={doc.title}>
{doc.title}
</span>
</ComposerSuggestionItem>
);
})}
</>
)}
{/* 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) && (
<ComposerSuggestionSeparator className="my-4" />
)}
<ComposerSuggestionGroupHeading>Folders</ComposerSuggestionGroupHeading>
{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 (
<ComposerSuggestionItem
key={folderKey}
ref={(el) => {
if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el);
else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex);
}}
icon={<FolderIcon className="size-4" />}
selected={isHighlighted}
disabled={isAlreadySelected}
onClick={() => !isAlreadySelected && handleSelectMention(folder)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
}
}}
>
<span className="flex-1 truncate text-sm" title={folder.title}>
{folder.title}
</span>
</ComposerSuggestionItem>
);
})}
</>
)}
{/* Pagination loading indicator */}
{isLoadingMore && ( {isLoadingMore && (
<div className="flex items-center justify-center py-2 text-primary"> <div className="flex items-center justify-center py-2 text-primary">
<Spinner size="sm" /> <Spinner size="sm" />
</div> </div>
)} )}
</ComposerSuggestionGroup> </ComposerSuggestionGroup>
) : (
<ComposerSuggestionMessage>No matching documents</ComposerSuggestionMessage>
)} )}
</ComposerSuggestionList> </ComposerSuggestionList>
); );

View file

@ -0,0 +1,120 @@
"use client";
import type * as React from "react";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
export type ComposerSuggestionNode<TValue> = {
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<TValue> = {
nodes: ComposerSuggestionNode<TValue>[];
onSelect: (node: ComposerSuggestionNode<TValue>) => void;
onBack?: () => boolean;
ref?: React.Ref<ComposerSuggestionNavigatorRef>;
};
export function useComposerSuggestionNavigator<TValue>({
nodes,
onSelect,
onBack,
ref,
}: ComposerSuggestionNavigatorOptions<TValue>) {
const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const nodesKey = useMemo(() => nodes.map((node) => node.id).join("\u0000"), [nodes]);
const previousNodesKeyRef = useRef<string | null>(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,
};
}

View file

@ -1,7 +1,7 @@
type MentionKeyInput = { type MentionKeyInput = {
id: number; id: number;
document_type?: string | null; document_type?: string | null;
kind?: "doc" | "folder"; kind?: "doc" | "folder" | "connector";
}; };
/** /**