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 (