From 1d5c364e1d51473497942776e2c29a4220f0371f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 18:30:29 +0200 Subject: [PATCH] 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. --- .../assistant-ui/inline-mention-editor.tsx | 12 +- .../components/assistant-ui/user-message.tsx | 42 ++++-- .../new-chat/document-mention-picker.tsx | 120 ++++++++++++++++-- 3 files changed, 154 insertions(+), 20 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 52e015c56..5fc942e54 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -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> = ({ 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> = ({ {isFolder ? ( + ) : isThread ? ( + ) : isConnector ? ( (getConnectorIcon( element.connector_type ?? element.document_type ?? "UNKNOWN", diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 5c90dce55..0c3649544 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -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 ? ( + ) : isThread ? ( + ) : isConnector ? ( (getConnectorIcon(segment.doc.connector_type, "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" /> diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 43a5cad74..620ebacf8 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -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 ; + if (mention.kind === "thread") return ; if (mention.kind === "connector") { return getConnectorIcon(mention.connector_type, "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[] { + return threads + .filter((thread) => thread.id !== currentChatId) + .map((thread) => makeThreadMention({ id: thread.id, title: thread.title })) + .filter( + (mention): mention is Extract => + 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: , + type: "branch", + value: { kind: "view", view: { kind: "chats" } }, + }); + } return nodes; - }, [activeConnectors.length, recentRootNodes]); + }, [activeConnectors.length, enableChatMentions, recentRootNodes]); const searchNodes = useMemo[]>(() => { 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: , + 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: , + 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 (