feat(web): enhance mention handling to support connectors and improve document key management

This commit is contained in:
Anish Sarkar 2026-05-26 21:52:04 +05:30
parent a41b16b73e
commit 2d134439ec
11 changed files with 160 additions and 164 deletions

View file

@ -20,7 +20,6 @@ import {
useMemo,
useRef,
} from "react";
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
@ -40,8 +39,6 @@ export interface MentionedDocument {
/**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``.
* Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE``
* so the dedup key never collides with a doc chip sharing the same id.
*/
export type MentionChipInput = {
id: number;
@ -78,7 +75,12 @@ export interface InlineMentionEditorRef {
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => void;
removeDocumentChip: (docId: number, docType?: string) => void;
removeDocumentChip: (
docId: number,
docType?: string,
kind?: MentionKind,
connectorType?: string
) => void;
setDocumentChipStatus: (
docId: number,
docType: string | undefined,
@ -95,7 +97,7 @@ interface InlineMentionEditorProps {
onActionClose?: () => void;
onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void;
onDocumentRemove?: (docId: number, docType?: string) => void;
onDocumentRemove?: (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean;
className?: string;
@ -135,7 +137,12 @@ const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
* the X button and Backspace go through the same call site.
*/
type MentionEditorContextValue = {
removeChip: (docId: number, docType: string | undefined) => void;
removeChip: (
docId: number,
docType: string | undefined,
kind: MentionKind | undefined,
connectorType: string | undefined
) => void;
};
const MentionEditorContext = createContext<MentionEditorContextValue | null>(null);
@ -181,7 +188,12 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
ctx.removeChip(element.id, element.document_type);
ctx.removeChip(
element.id,
element.document_type,
element.kind,
element.connector_type
);
}}
className="absolute inset-0 size-3 rounded-sm p-0 opacity-0 transition-opacity hover:bg-transparent hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-0 group-hover:opacity-100 [&_svg]:size-3"
>
@ -456,18 +468,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const removeTriggerText = options?.removeTriggerText ?? true;
const kind: MentionKind = mention.kind ?? "doc";
const document_type =
mention.document_type ??
(kind === "folder"
? FOLDER_MENTION_DOCUMENT_TYPE
: kind === "connector"
? mention.connector_type
: undefined);
const mentionNode: MentionElementNode = {
type: MENTION_TYPE,
id: mention.id,
title: mention.title,
document_type,
document_type: mention.document_type,
kind,
connector_type: mention.connector_type,
account_name: mention.account_name,
@ -526,17 +531,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[insertMentionChip]
);
// Remove chip(s) matching (id, document_type). Iterates in
// Remove chip(s) matching the mention identity. Iterates in
// descending path order so removing one entry can't invalidate
// later paths. Chips are deduped today, so this typically runs
// at most once.
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
(docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => {
const match = (n: unknown) => {
if (!n || typeof n !== "object" || !("type" in n)) return false;
const node = n as MentionElementNode;
if (node.type !== MENTION_TYPE) return false;
if (node.id !== docId) return false;
if (kind) {
return (
getMentionDocKey({
id: node.id,
kind: node.kind ?? "doc",
document_type: node.document_type,
connector_type: node.connector_type,
}) ===
getMentionDocKey({
id: docId,
kind,
document_type: docType,
connector_type: connectorType,
})
);
}
return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
};
@ -554,9 +575,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Single removal call site for Backspace and the X button so the
// two can never diverge (e.g. one forgetting to notify the parent).
const removeChip = useCallback(
(docId: number, docType: string | undefined) => {
removeDocumentChip(docId, docType);
onDocumentRemove?.(docId, docType);
(
docId: number,
docType: string | undefined,
kind: MentionKind | undefined,
connectorType: string | undefined
) => {
removeDocumentChip(docId, docType, kind, connectorType);
onDocumentRemove?.(docId, docType, kind, connectorType);
},
[onDocumentRemove, removeDocumentChip]
);
@ -679,7 +705,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (!isMentionNode(prev)) return;
e.preventDefault();
removeChip(prev.id, prev.document_type);
removeChip(prev.id, prev.document_type, prev.kind, prev.connector_type);
},
[editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip]
);

View file

@ -36,7 +36,6 @@ import {
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import {
FOLDER_MENTION_DOCUMENT_TYPE,
type MentionedDocumentInfo,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
@ -544,14 +543,12 @@ const Composer: FC = () => {
}
}
return docs.map<MentionedDocumentInfo>((d) => {
const documentType = d.document_type ?? "UNKNOWN";
if (d.kind === "connector") {
return {
id: d.id,
title: d.title,
document_type: documentType,
kind: "connector",
connector_type: d.connector_type ?? documentType,
connector_type: d.connector_type ?? "UNKNOWN",
account_name: d.account_name ?? d.title,
};
}
@ -559,17 +556,13 @@ const Composer: FC = () => {
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,
document_type: d.document_type ?? "UNKNOWN",
kind: "doc",
};
});
@ -760,13 +753,14 @@ const Composer: FC = () => {
]);
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
(docId: number, docType?: string, kind?: "doc" | "folder" | "connector", connectorType?: string) => {
setMentionedDocuments((prev) => {
if (!docType) {
// Fallback when chip type is unavailable.
return prev.filter((doc) => doc.id !== docId);
}
const removedKey = getMentionDocKey({ id: docId, document_type: docType });
const removedKey = getMentionDocKey({
id: docId,
document_type: docType,
kind,
connector_type: connectorType,
});
return prev.filter((doc) => getMentionDocKey(doc) !== removedKey);
});
},
@ -810,7 +804,12 @@ const Composer: FC = () => {
for (const [key, doc] of prevDocsMap) {
if (!nextDocsMap.has(key)) {
editor.removeDocumentChip(doc.id, doc.document_type);
editor.removeDocumentChip(
doc.id,
doc.kind === "doc" ? doc.document_type : undefined,
doc.kind,
doc.kind === "connector" ? doc.connector_type : undefined
);
}
}

View file

@ -104,7 +104,7 @@ const UserTextPart: FC = () => {
const icon = isFolder ? (
<FolderIcon className="size-3.5" />
) : isConnector ? (
getConnectorIcon(segment.doc.connector_type ?? segment.doc.document_type, "size-3.5") ?? (
getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
<Plug className="size-3.5" />
)
) : (