Merge pull request #1536 from CREDO23/feature-mention-chat-in-chat

[Feat] Chat : Reference past chats via @-mention as read-only context
This commit is contained in:
Rohan Verma 2026-06-25 13:32:25 -07:00 committed by GitHub
commit 96e42a1003
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 835 additions and 74 deletions

View file

@ -7,7 +7,7 @@ import {
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue, useSetAtom, useStore } from "jotai";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -23,9 +23,10 @@ import {
} from "@/atoms/chat/current-thread.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentIdsAtom,
deriveMentionedPayload,
mentionedDocumentsAtom,
messageDocumentsMapAtom,
submittedMentionsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
@ -214,7 +215,12 @@ const MentionedDocumentInfoSchema = z.object({
title: z.string(),
document_type: z.string().optional(),
kind: z
.union([z.literal("doc"), z.literal("folder"), z.literal("connector")])
.union([
z.literal("doc"),
z.literal("folder"),
z.literal("connector"),
z.literal("thread"),
])
.optional()
.default("doc"),
connector_type: z.string().optional(),
@ -252,6 +258,13 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
kind: "folder",
};
}
if (doc.kind === "thread") {
return {
id: doc.id,
title: doc.title,
kind: "thread",
};
}
return {
id: doc.id,
title: doc.title,
@ -441,8 +454,7 @@ export default function NewChatPage() {
// Get disabled tools from the tool toggle UI
const disabledTools = useAtomValue(disabledToolsAtom);
// Get mentioned document IDs from the composer.
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
const jotaiStore = useStore();
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
@ -964,6 +976,16 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
// Prefer the submit-time snapshot; fall back to the live atom
// for the send-button path.
const submittedSnapshot = jotaiStore.get(submittedMentionsAtom);
jotaiStore.set(submittedMentionsAtom, null);
const activeMentions = submittedSnapshot ?? mentionedDocuments;
const mentionPayload = deriveMentionedPayload(activeMentions);
if (activeMentions.length > 0) {
setMentionedDocuments([]);
}
const urlsSnapshot = [...pendingUserImageUrls];
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, urlsSnapshot);
@ -1061,9 +1083,9 @@ export default function NewChatPage() {
trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: userImages.length > 0,
hasMentionedDocuments:
mentionedDocumentIds.document_ids.length > 0 ||
mentionedDocumentIds.folder_ids.length > 0 ||
mentionedDocumentIds.connector_ids.length > 0,
mentionPayload.document_ids.length > 0 ||
mentionPayload.folder_ids.length > 0 ||
mentionPayload.connector_ids.length > 0,
messageLength: userQuery.length,
});
@ -1073,7 +1095,7 @@ export default function NewChatPage() {
// can render the correct chip type on reload.
const allMentionedDocs: MentionedDocumentInfo[] = [];
const seenDocKeys = new Set<string>();
for (const doc of mentionedDocuments) {
for (const doc of activeMentions) {
const key = getMentionDocKey(doc);
if (seenDocKeys.has(key)) continue;
seenDocKeys.add(key);
@ -1135,15 +1157,11 @@ export default function NewChatPage() {
})
.filter((m) => m.content.length > 0);
// Get mentioned document IDs for context (separate fields for backend)
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
const hasConnectorIds = mentionedDocumentIds.connector_ids.length > 0;
// Clear mentioned documents after capturing them
if (hasDocumentIds || hasFolderIds || hasConnectorIds) {
setMentionedDocuments([]);
}
// Backend expects each mention kind in its own payload bucket.
const hasDocumentIds = mentionPayload.document_ids.length > 0;
const hasFolderIds = mentionPayload.folder_ids.length > 0;
const hasConnectorIds = mentionPayload.connector_ids.length > 0;
const hasThreadIds = mentionPayload.thread_ids.length > 0;
const response = await fetchWithTurnCancellingRetry(() =>
fetch(buildBackendUrl("/api/v1/new_chat"), {
@ -1162,18 +1180,16 @@ export default function NewChatPage() {
local_filesystem_mounts: selection.local_filesystem_mounts,
messages: messageHistory,
mentioned_document_ids: hasDocumentIds
? mentionedDocumentIds.document_ids
? mentionPayload.document_ids
: undefined,
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
mentioned_folder_ids: hasFolderIds ? mentionPayload.folder_ids : undefined,
mentioned_connector_ids: hasConnectorIds
? mentionedDocumentIds.connector_ids
? mentionPayload.connector_ids
: undefined,
mentioned_connectors: hasConnectorIds ? mentionedDocumentIds.connectors : undefined,
// Full mention metadata (docs + folders, with
// ``kind`` discriminator) so the BE can embed a
// ``mentioned-documents`` ContentPart on the
// persisted user message (replaces the old FE-side
// injection in ``persistUserTurn``).
mentioned_connectors: hasConnectorIds ? mentionPayload.connectors : undefined,
mentioned_thread_ids: hasThreadIds ? mentionPayload.thread_ids : undefined,
// Full mention metadata so the backend can persist a
// ``mentioned-documents`` ContentPart on the user message.
mentioned_documents: allMentionedDocs.length > 0 ? allMentionedDocs : undefined,
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
...(userImages.length > 0 ? { user_images: userImages } : {}),
@ -1493,7 +1509,7 @@ export default function NewChatPage() {
threadId,
searchSpaceId,
messages,
mentionedDocumentIds,
jotaiStore,
mentionedDocuments,
setMentionedDocuments,
setMessageDocumentsMap,
@ -2061,6 +2077,9 @@ export default function NewChatPage() {
.filter((d) => d.kind === "folder")
.map((d) => d.id);
const regenerateConnectors = sourceMentionedDocs.filter((d) => d.kind === "connector");
const regenerateThreadIds = sourceMentionedDocs
.filter((d) => d.kind === "thread")
.map((d) => d.id);
const requestBody: Record<string, unknown> = {
search_space_id: searchSpaceId,
@ -2074,6 +2093,8 @@ export default function NewChatPage() {
mentioned_connector_ids:
regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
mentioned_connectors: regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
mentioned_thread_ids:
regenerateThreadIds.length > 0 ? regenerateThreadIds : undefined,
// Full mention metadata for the regenerate-specific
// source list. Only meaningful for edit (the BE only
// re-persists a user row when ``user_query`` is set);

View file

@ -28,6 +28,11 @@ export type MentionedDocumentInfo =
kind: "connector";
connector_type: string;
account_name: string;
}
| {
id: number;
title: string;
kind: "thread";
};
/**
@ -49,7 +54,10 @@ export function toMentionedDocumentInfo(
): MentionedDocumentInfo {
if (
"kind" in input &&
(input.kind === "doc" || input.kind === "folder" || input.kind === "connector")
(input.kind === "doc" ||
input.kind === "folder" ||
input.kind === "connector" ||
input.kind === "thread")
) {
return input;
}
@ -72,6 +80,18 @@ export function makeFolderMention(input: { id: number; name: string }): Mentione
};
}
/**
* Build a thread-mention chip from a thread row (id + title). Used to
* reference another conversation as read-only context.
*/
export function makeThreadMention(input: { id: number; title: string }): MentionedDocumentInfo {
return {
id: input.id,
title: input.title,
kind: "thread",
};
}
/**
* Atom to store the full context objects attached via @-mention chips in
* the current chat composer. Persists across component remounts.
@ -79,21 +99,26 @@ export function makeFolderMention(input: { id: number; name: string }): Mentione
export const mentionedDocumentsAtom = atom<MentionedDocumentInfo[]>([]);
/**
* Derived read-only atom that maps deduplicated mention chips into
* backend payload fields. Each mention kind maps to its own explicit
* payload bucket so non-document context never has to masquerade as a
* document type.
* Chips captured at submit time, so they survive the composer resetting
* the live atom on send. Consumed (and reset) by the send handler.
*/
export const mentionedDocumentIdsAtom = atom((get) => {
const allMentions = get(mentionedDocumentsAtom);
export const submittedMentionsAtom = atom<MentionedDocumentInfo[] | null>(null);
/**
* Map mention chips to their backend payload buckets. Each kind gets its
* own bucket so non-document context never masquerades as a document.
*/
export function deriveMentionedPayload(mentions: ReadonlyArray<MentionedDocumentInfo>) {
const seen = new Set<string>();
const deduped = allMentions.filter((m) => {
const deduped = mentions.filter((m) => {
const key =
m.kind === "doc"
? `doc:${m.document_type}:${m.id}`
: m.kind === "connector"
? `connector:${m.connector_type}:${m.id}`
: `folder:${m.id}`;
: m.kind === "thread"
? `thread:${m.id}`
: `folder:${m.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@ -101,10 +126,12 @@ export const mentionedDocumentIdsAtom = atom((get) => {
const docs = deduped.filter((m) => m.kind === "doc");
const folders = deduped.filter((m) => m.kind === "folder");
const connectors = deduped.filter((m) => m.kind === "connector");
const threads = deduped.filter((m) => m.kind === "thread");
return {
document_ids: docs.map((doc) => doc.id),
folder_ids: folders.map((f) => f.id),
connector_ids: connectors.map((c) => c.id),
thread_ids: threads.map((t) => t.id),
connectors: connectors.map((c) => ({
id: c.id,
title: c.title,
@ -113,7 +140,7 @@ export const mentionedDocumentIdsAtom = atom((get) => {
account_name: c.account_name,
})),
};
});
}
/**
* Atom to store mentioned chips per message ID.

View file

@ -1,6 +1,11 @@
"use client";
import { Folder as FolderIcon, Plug as PlugIcon, X as XIcon } from "lucide-react";
import {
Folder as FolderIcon,
MessageSquare as MessageSquareIcon,
Plug as PlugIcon,
X as XIcon,
} from "lucide-react";
import type { NodeEntry, TElement } from "platejs";
import type { PlateElementProps } from "platejs/react";
import {
@ -26,7 +31,7 @@ import type { Document } from "@/contracts/types/document.types";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
export type MentionKind = "doc" | "folder" | "connector";
export type MentionKind = "doc" | "folder" | "connector" | "thread";
export interface MentionedDocument {
id: number;
@ -165,6 +170,7 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
const isFolder = element.kind === "folder";
const isConnector = element.kind === "connector";
const isThread = element.kind === "thread";
const ctx = useContext(MentionEditorContext);
return (
@ -175,6 +181,8 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
{isFolder ? (
<FolderIcon className="h-3 w-3" />
) : isThread ? (
<MessageSquareIcon className="h-3 w-3" />
) : isConnector ? (
(getConnectorIcon(
element.connector_type ?? element.document_type ?? "UNKNOWN",

View file

@ -38,6 +38,7 @@ import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentsAtom,
submittedMentionsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
@ -446,6 +447,7 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
const Composer: FC = () => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
@ -575,6 +577,13 @@ const Composer: FC = () => {
kind: "folder",
};
}
if (d.kind === "thread") {
return {
id: d.id,
title: d.title,
kind: "thread",
};
}
return {
id: d.id,
title: d.title,
@ -770,6 +779,10 @@ const Composer: FC = () => {
setClipboardInitialText(undefined);
}
// Capture chips before the reset below clears the live atom, so
// the async ``onNew`` still sees them.
setSubmittedMentions(mentionedDocuments);
aui.composer().send();
editorRef.current?.clear();
setIsComposerInputEmpty(true);
@ -781,6 +794,8 @@ const Composer: FC = () => {
isBlockedByOtherUser,
clipboardInitialText,
aui,
mentionedDocuments,
setSubmittedMentions,
setMentionedDocuments,
]);
@ -788,7 +803,7 @@ const Composer: FC = () => {
(
docId: number,
docType?: string,
kind?: "doc" | "folder" | "connector",
kind?: "doc" | "folder" | "connector" | "thread",
connectorType?: string
) => {
setMentionedDocuments((prev) => {
@ -876,6 +891,8 @@ const Composer: FC = () => {
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
enableChatMentions
currentChatId={threadId}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);

View file

@ -6,9 +6,16 @@ import {
useMessagePartText,
} from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil, Plug } from "lucide-react";
import {
CheckIcon,
CopyIcon,
Folder as FolderIcon,
MessageSquare,
Pencil,
Plug,
} from "lucide-react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { type FC, useCallback, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
@ -66,6 +73,7 @@ const UserTextPart: FC = () => {
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = (messageId ? messageDocumentsMap[messageId] : undefined) ?? [];
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const router = useRouter();
const params = useParams();
const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
@ -91,6 +99,17 @@ const UserTextPart: FC = () => {
[openEditorPanel, resolvedSearchSpaceId]
);
const handleOpenThread = useCallback(
(threadId: number) => {
if (!resolvedSearchSpaceId) {
toast.error("Cannot open chat outside a search space.");
return;
}
router.push(`/dashboard/${resolvedSearchSpaceId}/new-chat/${threadId}`);
},
[resolvedSearchSpaceId, router]
);
const segments = parseMentionSegments(text, mentionedDocs);
return (
@ -101,8 +120,11 @@ const UserTextPart: FC = () => {
}
const isFolder = segment.doc.kind === "folder";
const isConnector = segment.doc.kind === "connector";
const isThread = segment.doc.kind === "thread";
const icon = isFolder ? (
<FolderIcon className="size-3.5" />
) : isThread ? (
<MessageSquare className="size-3.5" />
) : isConnector ? (
(getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
<Plug className="size-3.5" />
@ -118,14 +140,18 @@ const UserTextPart: FC = () => {
tooltip={
isFolder
? `Folder: ${segment.doc.title}`
: isConnector
? `Connector account: ${segment.doc.title}`
: segment.doc.title
: isThread
? `Chat: ${segment.doc.title}`
: isConnector
? `Connector account: ${segment.doc.title}`
: segment.doc.title
}
onClick={
isFolder || isConnector
? undefined
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
isThread
? () => handleOpenThread(segment.doc.id)
: isFolder || isConnector
? undefined
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
}
className="mx-0.5"
/>

View file

@ -3,7 +3,14 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { ChevronLeft, ChevronRight, Files, Folder as FolderIcon, Unplug } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Files,
Folder as FolderIcon,
MessageSquare,
Unplug,
} from "lucide-react";
import {
Fragment,
forwardRef,
@ -15,7 +22,10 @@ import {
useRef,
useState,
} from "react";
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
import {
type MentionedDocumentInfo,
makeThreadMention,
} from "@/atoms/chat/mentioned-documents.atom";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { getConnectorTitle } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab";
@ -40,6 +50,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { searchThreads } from "@/lib/chat/thread-persistence";
import { queries } from "@/zero/queries";
export type DocumentMentionPickerRef = ComposerSuggestionNavigatorRef;
@ -50,6 +61,14 @@ interface DocumentMentionPickerProps {
onDone: () => void;
initialSelectedDocuments?: MentionedDocumentInfo[];
externalSearch?: string;
/**
* Surface the "Chats" view so the user can reference other
* conversations. Off by default so non-chat callers (e.g. automation
* task inputs) keep their original doc/folder/connector surface.
*/
enableChatMentions?: boolean;
/** Active thread id, excluded so a chat can't reference itself. */
currentChatId?: number | null;
}
const PAGE_SIZE = 20;
@ -62,7 +81,8 @@ type BrowseView =
| { kind: "root" }
| { kind: "files-folders" }
| { kind: "connectors" }
| { kind: "connector-type"; connectorType: string; title: string };
| { kind: "connector-type"; connectorType: string; title: string }
| { kind: "chats" };
type ResourceNodeValue =
| { kind: "view"; view: BrowseView }
@ -78,6 +98,7 @@ function isMentionedContextItem(value: unknown): value is MentionedDocumentInfo
if (typeof item.id !== "number" || typeof item.title !== "string") return false;
if (item.kind === "doc") return typeof item.document_type === "string";
if (item.kind === "folder") return true;
if (item.kind === "thread") return true;
if (item.kind === "connector") {
return typeof item.connector_type === "string" && typeof item.account_name === "string";
}
@ -125,6 +146,7 @@ export function promoteRecentMention(searchSpaceId: number, mention: MentionedDo
function getMentionIcon(mention: MentionedDocumentInfo) {
if (mention.kind === "folder") return <FolderIcon className="size-4" />;
if (mention.kind === "thread") return <MessageSquare className="size-4" />;
if (mention.kind === "connector") {
return getConnectorIcon(mention.connector_type, "size-4") ?? <Unplug className="size-4" />;
}
@ -149,6 +171,11 @@ function refreshRecentMention(
const folder = folders.find((item) => item.id === mention.id);
return folder ? makeFolderMention({ id: folder.id, title: folder.name }) : null;
}
if (mention.kind === "thread") {
// Threads aren't in the doc/folder/connector lists; keep the
// recent as-is (validated against the live thread search instead).
return mention;
}
const connector = connectors.find(
(item) => item.id === mention.id && item.connector_type === mention.connector_type
);
@ -216,11 +243,32 @@ function mentionMatchesSearch(mention: MentionedDocumentInfo, searchLower: strin
].some((value) => value.toLowerCase().includes(searchLower));
}
function makeThreadMentions(
threads: { id: number; title: string }[],
currentChatId?: number | null
): Extract<MentionedDocumentInfo, { kind: "thread" }>[] {
return threads
.filter((thread) => thread.id !== currentChatId)
.map((thread) => makeThreadMention({ id: thread.id, title: thread.title }))
.filter(
(mention): mention is Extract<MentionedDocumentInfo, { kind: "thread" }> =>
mention.kind === "thread"
);
}
export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef,
DocumentMentionPickerProps
>(function DocumentMentionPicker(
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
{
searchSpaceId,
onSelectionChange,
onDone,
initialSelectedDocuments = [],
externalSearch = "",
enableChatMentions = false,
currentChatId = null,
},
ref
) {
const search = externalSearch;
@ -353,6 +401,21 @@ export const DocumentMentionPicker = forwardRef<
() => activeConnectors.map(makeConnectorMention),
[activeConnectors]
);
// Threads are fetched on demand: when the user opens the Chats view
// or types a search. An empty title returns recent threads (the
// backend ``ilike '%%'`` matches all, newest first).
const { data: threadResults = [], isLoading: isThreadsLoading } = useQuery({
queryKey: ["composer-mention-threads", searchSpaceId, debouncedSearch],
queryFn: () => searchThreads(searchSpaceId, debouncedSearch.trim()),
staleTime: 60 * 1000,
enabled: enableChatMentions && !!searchSpaceId && (view.kind === "chats" || hasSearch),
placeholderData: keepPreviousData,
});
const threadMentions = useMemo(
() => (enableChatMentions ? makeThreadMentions(threadResults, currentChatId) : []),
[enableChatMentions, threadResults, currentChatId]
);
const recentDocMentions = useMemo(
() => recentMentions.filter((mention) => mention.kind === "doc"),
[recentMentions]
@ -447,10 +510,20 @@ export const DocumentMentionPicker = forwardRef<
type: "branch",
disabled: activeConnectors.length === 0,
value: { kind: "view", view: { kind: "connectors" } },
}
},
);
if (enableChatMentions) {
nodes.push({
id: "chats",
label: "Chats",
subtitle: "Reference another conversation",
icon: <MessageSquare className="size-4" />,
type: "branch",
value: { kind: "view", view: { kind: "chats" } },
});
}
return nodes;
}, [activeConnectors.length, recentRootNodes]);
}, [activeConnectors.length, enableChatMentions, recentRootNodes]);
const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch)
@ -488,7 +561,17 @@ export const DocumentMentionPicker = forwardRef<
value: { kind: "mention" as const, mention },
}));
return [...docNodes, ...folderNodes, ...connectorNodes];
const threadNodes = threadMentions.map((mention) => ({
id: getMentionDocKey(mention),
label: mention.title,
subtitle: "Chat",
icon: <MessageSquare className="size-4" />,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
}));
return [...docNodes, ...folderNodes, ...connectorNodes, ...threadNodes];
}, [
actualDocuments,
connectorMentions,
@ -497,6 +580,7 @@ export const DocumentMentionPicker = forwardRef<
folderMentions,
isSingleCharSearch,
selectedKeys,
threadMentions,
]);
const connectorTypeEntries = useMemo(() => {
@ -536,6 +620,17 @@ export const DocumentMentionPicker = forwardRef<
});
return [...folders, ...docs];
}
if (view.kind === "chats") {
return threadMentions.map((mention) => ({
id: getMentionDocKey(mention),
label: mention.title,
subtitle: "Chat",
icon: <MessageSquare className="size-4" />,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
}));
}
if (view.kind === "connectors") {
return connectorTypeEntries.map(([connectorType, typeConnectors]) => ({
id: `connector-type:${connectorType}`,
@ -576,6 +671,7 @@ export const DocumentMentionPicker = forwardRef<
folderMentions,
rootNodes,
selectedKeys,
threadMentions,
view,
]);
@ -625,12 +721,14 @@ export const DocumentMentionPicker = forwardRef<
const isRootBrowseView = !hasSearch && view.kind === "root";
const isVisibleViewLoading = hasSearch
? isTitleSearchLoading || isConnectorsLoading
? isTitleSearchLoading || isConnectorsLoading || isThreadsLoading
: view.kind === "files-folders"
? isTitleSearchLoading
: view.kind === "connectors" || view.kind === "connector-type"
? isConnectorsLoading
: false;
: view.kind === "chats"
? isThreadsLoading
: false;
const actualLoading =
isVisibleViewLoading && !isSingleCharSearch && visibleNodes.length === 0 && !isRootBrowseView;
@ -641,7 +739,9 @@ export const DocumentMentionPicker = forwardRef<
? "Files & Folders"
: view.kind === "connectors"
? "Connectors"
: view.title;
: view.kind === "chats"
? "Chats"
: view.title;
return (
<ComposerSuggestionList

View file

@ -2,19 +2,20 @@ type MentionKeyInput = {
id: number;
document_type?: string | null;
connector_type?: string | null;
kind?: "doc" | "folder" | "connector";
kind?: "doc" | "folder" | "connector" | "thread";
};
/**
* Build a stable dedup key for a mention chip.
*
* Each mention kind keys off its real identity fields:
* docs by document type, folders by folder id, and connectors by
* connector type + account id.
* docs by document type, folders by folder id, connectors by
* connector type + account id, and threads by thread id.
*/
export function getMentionDocKey(doc: MentionKeyInput): string {
const kind = doc.kind ?? "doc";
if (kind === "folder") return `folder:${doc.id}`;
if (kind === "thread") return `thread:${doc.id}`;
if (kind === "connector") return `connector:${doc.connector_type ?? "UNKNOWN"}:${doc.id}`;
return `doc:${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
}