mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(web): enhance chat context and mention handling with connector support
This commit is contained in:
parent
701ae800b4
commit
a41b16b73e
15 changed files with 773 additions and 449 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 ?? [];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue