mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
feat(chat): surface chat references in the @-mention UI
Add a Chats tab to the mention picker (excluding the current chat), carry the "thread" kind through the inline editor's chip nodes, and render thread chips on user messages with navigation to the referenced conversation.
This commit is contained in:
parent
1f6934b980
commit
1d5c364e1d
3 changed files with 154 additions and 20 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue