From 8ea042e88c59030b9c21fa085cfd079a37c33c13 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 12 May 2026 20:57:15 +0530 Subject: [PATCH] refactor(chat): improve user query handling and mention chip functionality --- .../app/tasks/chat/stream_new_chat.py | 22 ++-- .../assistant-ui/inline-mention-editor.tsx | 117 +++++++++++++----- .../components/assistant-ui/mention-chip.tsx | 10 +- 3 files changed, 107 insertions(+), 42 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 818282996..aed62ece9 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1507,14 +1507,20 @@ async def stream_new_chat( # Resolve @-mention chips to canonical virtual paths and rewrite # the user-typed text so the LLM sees ``\`/documents/...\``` instead - # of bare ``@title``. The persisted user-message text keeps - # ``@title`` so chip rendering on reload is unchanged — see - # ``persistence._build_user_content``. + # of bare ``@title``. The substitution lands in ``agent_user_query`` + # ONLY — the original ``user_query`` (with ``@title`` tokens) flows + # untouched into ``persist_user_turn`` below so chip rendering on + # reload still works (``UserTextPart`` → ``parseMentionSegments`` + # matches ``@title``, not ``\`/documents/...\```). It also feeds + # the human-readable surfaces — SSE "Processing X" status, auto + # thread title, memory seed — which all want what the user typed. + # See ``persistence._build_user_content``. # # Cloud mode only: local-folder mode keeps the legacy # ``@title`` text path; mention support there is a follow-up # task because the path scheme (mount-rooted) and the picker # UI both need separate work. + agent_user_query = user_query accepted_folder_ids: list[int] = [] if fs_mode == FilesystemMode.CLOUD.value and ( mentioned_document_ids @@ -1549,11 +1555,13 @@ async def stream_new_chat( mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids, mentioned_folder_ids=mentioned_folder_ids, ) - user_query = substitute_in_text(user_query, resolved.token_to_path) + agent_user_query = substitute_in_text(user_query, resolved.token_to_path) accepted_folder_ids = resolved.mentioned_folder_ids - # Format the user query with context (SurfSense docs + reports only) - final_query = user_query + # Format the user query with context (SurfSense docs + reports only). + # Uses ``agent_user_query`` so the LLM sees backtick-wrapped paths + # instead of bare ``@title`` tokens. + final_query = agent_user_query context_parts = [] if mentioned_surfsense_docs: @@ -1584,7 +1592,7 @@ async def stream_new_chat( if context_parts: context = "\n\n".join(context_parts) - final_query = f"{context}\n\n{user_query}" + final_query = f"{context}\n\n{agent_user_query}" if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name: final_query = f"**[{current_user_display_name}]:** {final_query}" diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index e12556486..c2b896794 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { Folder as FolderIcon } from "lucide-react"; +import { Folder as FolderIcon, X as XIcon } from "lucide-react"; import type { PlateElementProps } from "platejs/react"; import { createPlatePlugin, @@ -9,7 +9,16 @@ import { PlateContent, usePlateEditor, } from "platejs/react"; -import { type FC, forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from "react"; +import { + createContext, + type FC, + forwardRef, + useCallback, + useContext, + useImperativeHandle, + useMemo, + useRef, +} from "react"; import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; @@ -107,13 +116,25 @@ type ComposerValue = ComposerParagraph[]; const MENTION_TYPE = "mention"; const MENTION_CHIP_CLASSNAME = - "inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none"; + "group inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none"; const MENTION_CHIP_ICON_CLASSNAME = "flex items-center text-muted-foreground leading-none"; const MENTION_CHIP_TITLE_CLASSNAME = "max-w-[120px] truncate leading-none"; const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6"; const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }]; +/** + * Internal seam that lets ``MentionElement`` (a Plate render component + * with no React props beyond ``element``) reach the editor's chip-removal + * function. Mirrors the Backspace path in ``handleKeyDown`` so the X + * button delegates to the exact same combined call site — no extra + * state, no atom coupling leaking into the chip. + */ +type MentionEditorContextValue = { + removeChip: (docId: number, docType: string | undefined) => void; +}; +const MentionEditorContext = createContext(null); + const MentionElement: FC> = ({ attributes, children, @@ -127,16 +148,36 @@ const MentionElement: FC> = ({ : "text-amber-700"; const isFolder = element.kind === "folder"; + const ctx = useContext(MentionEditorContext); return ( - {isFolder ? ( - - ) : ( - getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3") - )} + + + {isFolder ? ( + + ) : ( + getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3") + )} + + {ctx ? ( + + ) : null} + {element.title} @@ -464,6 +505,18 @@ export const InlineMentionEditor = forwardRef { + removeDocumentChip(docId, docType); + onDocumentRemove?.(docId, docType); + }, + [onDocumentRemove, removeDocumentChip] + ); + const setDocumentChipStatus = useCallback( ( docId: number, @@ -568,10 +621,9 @@ export const InlineMentionEditor = forwardRef( + () => ({ removeChip }), + [removeChip] + ); + return (
- { - emitState(value as ComposerValue); - }} - > - - + + { + emitState(value as ComposerValue); + }} + > + + +
); } diff --git a/surfsense_web/components/assistant-ui/mention-chip.tsx b/surfsense_web/components/assistant-ui/mention-chip.tsx index 9f9c9b177..7197fccdc 100644 --- a/surfsense_web/components/assistant-ui/mention-chip.tsx +++ b/surfsense_web/components/assistant-ui/mention-chip.tsx @@ -66,23 +66,21 @@ export function MentionChip({ disabled={disabled} aria-label={ariaLabel ?? label} className={cn( - "inline-flex max-w-[220px] items-center gap-1.5 rounded-md border bg-background px-2 py-0.5 align-middle text-xs font-medium text-foreground leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", - isInteractive - ? "cursor-pointer hover:bg-accent hover:text-accent-foreground" - : "cursor-default", + "inline-flex h-5 items-center gap-1 rounded bg-primary/10 px-1 align-middle text-xs font-bold text-primary/60 leading-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + isInteractive ? "cursor-pointer" : "cursor-default", disabled && "opacity-60", className )} > {icon} - {label} + {label} ); if (!tooltip) return chip; return ( - + {chip} {tooltip}