feat: improved document, folder mentions rendering
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-09 22:15:51 -07:00
parent 28a02a9143
commit c8374e6c5b
59 changed files with 1725 additions and 361 deletions

View file

@ -1,5 +1,6 @@
"use client";
import { Folder as FolderIcon } from "lucide-react";
import type { PlateElementProps } from "platejs/react";
import {
createPlatePlugin,
@ -9,23 +10,51 @@ import {
usePlateEditor,
} from "platejs/react";
import { type FC, forwardRef, useCallback, 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";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
export type MentionKind = "doc" | "folder";
export interface MentionedDocument {
id: number;
title: string;
document_type?: string;
kind: MentionKind;
}
/**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``
* when omitted so legacy callers don't have to thread the
* discriminator. Folder callers pass ``kind: "folder"`` and the
* folder ``id`` and ``title``; ``document_type`` defaults to
* ``FOLDER_MENTION_DOCUMENT_TYPE`` inside ``insertMentionChip`` so the
* dedup key (`kind:document_type:id`) never collides with a doc chip
* that happens to share an id.
*/
export type MentionChipInput = {
id: number;
title: string;
document_type?: string;
kind?: MentionKind;
};
export interface InlineMentionEditorRef {
focus: () => void;
clear: () => void;
setText: (text: string) => void;
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertMentionChip: (
mention: MentionChipInput,
options?: { removeTriggerText?: boolean }
) => void;
/**
* @deprecated Use ``insertMentionChip``. Kept for one transition
* cycle so we don't break ad-hoc callers; prefer the new name.
*/
insertDocumentChip: (
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
@ -61,6 +90,13 @@ type MentionElementNode = {
id: number;
title: string;
document_type?: string;
/**
* Discriminator added so a folder chip and a doc chip with the
* same id round-trip cleanly through ``getMentionedDocuments``
* and the persisted ``mentioned-documents`` content part.
* Defaults to ``"doc"`` for nodes that predate this field.
*/
kind?: MentionKind;
statusLabel?: string | null;
statusKind?: MentionStatusKind;
children: [{ text: "" }];
@ -90,11 +126,17 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
? "text-emerald-700"
: "text-amber-700";
const isFolder = element.kind === "folder";
return (
<span {...attributes} className="inline-flex align-middle">
<span contentEditable={false} className={`${MENTION_CHIP_CLASSNAME} cursor-default`}>
<span className={MENTION_CHIP_ICON_CLASSNAME}>
{getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")}
{isFolder ? (
<FolderIcon className="h-3 w-3" />
) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
</span>
<span className={MENTION_CHIP_TITLE_CLASSNAME} title={element.title}>
{element.title}
@ -153,10 +195,12 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
for (const block of value) {
for (const node of block.children) {
if (!isMentionNode(node)) continue;
const kind: MentionKind = node.kind ?? "doc";
const doc: MentionedDocument = {
id: node.id,
title: node.title,
document_type: node.document_type,
kind,
};
map.set(getMentionDocKey(doc), doc);
}
@ -311,21 +355,23 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, emitState]
);
const insertDocumentChip = useCallback(
(
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => {
if (typeof doc.id !== "number" || typeof doc.title !== "string") return;
const insertMentionChip = useCallback(
(mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => {
if (typeof mention.id !== "number" || typeof mention.title !== "string") return;
const removeTriggerText = options?.removeTriggerText ?? true;
const current = getCurrentValue();
const selection = editor.selection;
const kind: MentionKind = mention.kind ?? "doc";
const document_type =
mention.document_type ??
(kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
const mentionNode: MentionElementNode = {
type: MENTION_TYPE,
id: doc.id,
title: doc.title,
document_type: doc.document_type,
id: mention.id,
title: mention.title,
document_type,
kind,
children: [{ text: "" }],
};
@ -385,6 +431,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor.selection, focusAtEnd, getCurrentValue, setValue]
);
// Backwards-compatible shim — pre-folder callers pass a doc-only
// payload; we route them through ``insertMentionChip`` with
// ``kind: "doc"``.
const insertDocumentChip = useCallback(
(
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => {
insertMentionChip({ ...doc, kind: "doc" }, options);
},
[insertMentionChip]
);
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
const current = getCurrentValue();
@ -460,6 +519,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
setText,
getText,
getMentionedDocuments: getMentionedDocs,
insertMentionChip,
insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus,
@ -468,6 +528,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
clear,
getMentionedDocs,
getText,
insertMentionChip,
insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus,

View file

@ -8,7 +8,7 @@ import {
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import { useSetAtom } from "jotai";
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, FileIcon, Folder as FolderIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
@ -18,6 +18,7 @@ import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
import { MentionChip } from "@/components/assistant-ui/mention-chip";
import "katex/dist/katex.min.css";
import { toast } from "sonner";
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
@ -33,6 +34,7 @@ import {
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
import { getVirtualPathDisplay } from "@/lib/chat/virtual-path-display";
import { cn } from "@/lib/utils";
function MarkdownCodeBlockSkeleton() {
@ -219,59 +221,71 @@ function FilePathLink({ path, className }: { path: string; className?: string })
? parsedSearchSpaceId
: undefined;
return (
<button
type="button"
className={cn(
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80",
className
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void (async () => {
if (electronAPI) {
let resolvedLocalPath = path;
if (electronAPI.getAgentFilesystemMounts) {
try {
const mounts = (await electronAPI.getAgentFilesystemMounts(
resolvedSearchSpaceId
)) as AgentFilesystemMount[];
resolvedLocalPath = normalizeLocalVirtualPathForEditor(path, mounts);
} catch {
// Fall back to the raw path if mount lookup fails.
}
}
openEditorPanel({
kind: "local_file",
localFilePath: resolvedLocalPath,
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
searchSpaceId: resolvedSearchSpaceId,
});
return;
}
const { displayName, isFolder } = getVirtualPathDisplay(path);
const icon = isFolder ? (
<FolderIcon className="size-3.5" />
) : (
<FileIcon className="size-3.5" />
);
if (!resolvedSearchSpaceId || !path.startsWith("/documents/")) return;
try {
const doc = await documentsApiService.getDocumentByVirtualPath({
search_space_id: resolvedSearchSpaceId,
virtual_path: path,
});
openEditorPanel({
kind: "document",
documentId: doc.id,
searchSpaceId: resolvedSearchSpaceId,
title: doc.title,
});
} catch {
toast.error("Document not found in knowledge base.");
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
void (async () => {
if (electronAPI) {
let resolvedLocalPath = path;
if (electronAPI.getAgentFilesystemMounts) {
try {
const mounts = (await electronAPI.getAgentFilesystemMounts(
resolvedSearchSpaceId
)) as AgentFilesystemMount[];
resolvedLocalPath = normalizeLocalVirtualPathForEditor(path, mounts);
} catch {
// Fall back to the raw path if mount lookup fails.
}
}
})();
}}
title="Open in editor panel"
>
{path}
</button>
openEditorPanel({
kind: "local_file",
localFilePath: resolvedLocalPath,
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
searchSpaceId: resolvedSearchSpaceId,
});
return;
}
if (!resolvedSearchSpaceId || !path.startsWith("/documents/")) return;
try {
const doc = await documentsApiService.getDocumentByVirtualPath({
search_space_id: resolvedSearchSpaceId,
virtual_path: path,
});
openEditorPanel({
kind: "document",
documentId: doc.id,
searchSpaceId: resolvedSearchSpaceId,
title: doc.title,
});
} catch {
toast.error("Document not found in knowledge base.");
}
})();
},
[electronAPI, openEditorPanel, path, resolvedSearchSpaceId]
);
// Folders cannot open in the editor panel — keep them as visual chips.
const onClick = isFolder ? undefined : handleClick;
return (
<MentionChip
icon={icon}
label={displayName || path}
tooltip={path}
onClick={onClick}
ariaLabel={isFolder ? `Folder ${displayName}` : `Open ${displayName}`}
className={className}
/>
);
}

View file

@ -0,0 +1,92 @@
"use client";
import type { MouseEventHandler, ReactNode } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* A single, minimal chip-button used in two places:
*
* 1. User-message mention chips (rendered for every `@`-mention the user
* inserted in the composer).
* 2. AI-answer file/folder paths (rendered when the assistant emits
* `/documents/.../file.xml` or `/<mount>/.../file.ext`).
*
* Both contexts want the same visual language: a compact, button-styled
* chip with an icon, a truncated label, and an optional tooltip. Sharing
* one component keeps the chat surface visually coherent and means a UX
* tweak (radius, hover, icon size) lands in both places at once.
*
* Styling rules (per shadcn skill):
* - Semantic tokens only (`border`, `bg-background`, `bg-accent`,
* `text-foreground`, `text-muted-foreground`). No raw colors.
* - Layout via `gap-*`, never `space-x-*`.
* - `cn()` for conditional classes.
* - No manual `z-index` the tooltip handles its own stacking.
*/
export interface MentionChipProps {
/**
* Visual prefix. Keep this small (e.g. `size-3.5`); the chip controls
* its own height and oversized icons will push the label out of place.
*/
icon: ReactNode;
/** Label shown inside the chip; truncated with `…` past the max width. */
label: string;
/**
* Full title or path shown on hover. Omit to suppress the tooltip
* entirely (e.g. when the label already conveys the full identity).
*/
tooltip?: ReactNode;
/**
* When provided, the chip behaves like a button (focusable, hover
* effect, pointer cursor). Omit for a purely decorative chip.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
className?: string;
/** Optional override for the accessible name; defaults to `label`. */
ariaLabel?: string;
}
export function MentionChip({
icon,
label,
tooltip,
onClick,
disabled,
className,
ariaLabel,
}: MentionChipProps) {
const isInteractive = Boolean(onClick) && !disabled;
const chip = (
<button
type="button"
onClick={onClick}
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",
disabled && "opacity-60",
className
)}
>
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
<span className="truncate">{label}</span>
</button>
);
if (!tooltip) return chip;
return (
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs break-all">
{tooltip}
</TooltipContent>
</Tooltip>
);
}

View file

@ -36,7 +36,10 @@ import {
} from "@/atoms/agent-tools/agent-tools.atoms";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
clearPremiumAlertForThreadAtom,
@ -87,7 +90,6 @@ import {
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
@ -377,9 +379,7 @@ const Composer: FC = () => {
const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
const prevMentionedDocsRef = useRef<
Map<string, Pick<Document, "id" | "title" | "document_type">>
>(new Map());
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams();
@ -622,20 +622,20 @@ const Composer: FC = () => {
);
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
(mentions: MentionedDocumentInfo[]) => {
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
for (const doc of documents) {
const key = getMentionDocKey(doc);
for (const mention of mentions) {
const key = getMentionDocKey(mention);
if (editorDocKeys.has(key)) continue;
editorRef.current?.insertDocumentChip(doc);
editorRef.current?.insertMentionChip(mention);
}
setMentionedDocuments((prev) => {
const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d)));
const uniqueNewDocs = documents.filter((doc) => !existingKeySet.has(getMentionDocKey(doc)));
return [...prev, ...uniqueNewDocs];
const uniqueNew = mentions.filter((m) => !existingKeySet.has(getMentionDocKey(m)));
return [...prev, ...uniqueNew];
});
setMentionQuery("");
@ -657,7 +657,7 @@ const Composer: FC = () => {
for (const [key, doc] of nextDocsMap) {
if (prevDocsMap.has(key) || editorKeys.has(key)) continue;
editor.insertDocumentChip(doc, { removeTriggerText: false });
editor.insertMentionChip(doc, { removeTriggerText: false });
}
for (const [key, doc] of prevDocsMap) {

View file

@ -5,12 +5,16 @@ import {
useAuiState,
useMessagePartText,
} from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, Pencil } from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react";
import Image from "next/image";
import { type FC, useState } from "react";
import { useParams } from "next/navigation";
import { type FC, useCallback, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { MentionChip } from "@/components/assistant-ui/mention-chip";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
@ -61,27 +65,61 @@ const UserTextPart: FC = () => {
const text = (part as { text?: string }).text ?? "";
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = (messageId ? messageDocumentsMap[messageId] : undefined) ?? [];
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const params = useParams();
const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
? Number(searchSpaceIdParam[0])
: Number(searchSpaceIdParam);
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
? parsedSearchSpaceId
: undefined;
const handleOpenDoc = useCallback(
(docId: number, title: string) => {
if (!resolvedSearchSpaceId) {
toast.error("Cannot open document outside a search space.");
return;
}
openEditorPanel({
kind: "document",
documentId: docId,
searchSpaceId: resolvedSearchSpaceId,
title,
});
},
[openEditorPanel, resolvedSearchSpaceId]
);
const segments = parseMentionSegments(text, mentionedDocs);
return (
<p style={{ whiteSpace: "pre-line" }} className="break-words">
{segments.map((segment) =>
segment.type === "text" ? (
<span key={`txt-${segment.start}`}>{segment.value}</span>
<p style={{ whiteSpace: "pre-line" }} className="wrap-break-word">
{segments.map((segment) => {
if (segment.type === "text") {
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
}
const isFolder = segment.doc.kind === "folder";
const icon = isFolder ? (
<FolderIcon className="size-3.5" />
) : (
<span
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
);
return (
<MentionChip
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
className="inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none align-middle leading-none"
title={segment.doc.title}
>
<span className="flex items-center text-muted-foreground">
{getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "h-3 w-3")}
</span>
<span className="max-w-[120px] truncate">{segment.doc.title}</span>
</span>
)
)}
icon={icon}
label={segment.doc.title}
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
onClick={
isFolder
? undefined
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
}
className="mx-0.5"
/>
);
})}
</p>
);
};