mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(web): implement recent mention management in document mention picker and enhance composer component
This commit is contained in:
parent
79f5e8f88c
commit
17293125ef
4 changed files with 243 additions and 73 deletions
|
|
@ -70,6 +70,7 @@ import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||||
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
||||||
import {
|
import {
|
||||||
DocumentMentionPicker,
|
DocumentMentionPicker,
|
||||||
|
promoteRecentMention,
|
||||||
type DocumentMentionPickerRef,
|
type DocumentMentionPickerRef,
|
||||||
} from "../new-chat/document-mention-picker";
|
} from "../new-chat/document-mention-picker";
|
||||||
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
||||||
|
|
@ -768,6 +769,7 @@ const Composer: FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
|
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
|
||||||
|
const parsedSearchSpaceId = Number(search_space_id);
|
||||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||||
|
|
||||||
|
|
@ -775,6 +777,9 @@ const Composer: FC = () => {
|
||||||
const key = getMentionDocKey(mention);
|
const key = getMentionDocKey(mention);
|
||||||
if (editorDocKeys.has(key)) continue;
|
if (editorDocKeys.has(key)) continue;
|
||||||
editorRef.current?.insertMentionChip(mention);
|
editorRef.current?.insertMentionChip(mention);
|
||||||
|
if (Number.isFinite(parsedSearchSpaceId)) {
|
||||||
|
promoteRecentMention(parsedSearchSpaceId, mention);
|
||||||
|
}
|
||||||
// Track within the loop so a duplicate-in-batch can't double-insert.
|
// Track within the loop so a duplicate-in-batch can't double-insert.
|
||||||
editorDocKeys.add(key);
|
editorDocKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
@ -783,7 +788,7 @@ const Composer: FC = () => {
|
||||||
// onChange — no second write path here.
|
// onChange — no second write path here.
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
setSuggestionAnchorPoint(null);
|
setSuggestionAnchorPoint(null);
|
||||||
}, []);
|
}, [search_space_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ function ComposerSuggestionPopoverContent({
|
||||||
onCloseAutoFocus?.(event);
|
onCloseAutoFocus?.(event);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[256px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[288px]",
|
"w-[232px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[264px]",
|
||||||
"data-[state=open]:!animate-none data-[state=closed]:!animate-none data-[state=open]:!duration-0 data-[state=closed]:!duration-0",
|
"data-[state=open]:!animate-none data-[state=closed]:!animate-none data-[state=open]:!duration-0 data-[state=closed]:!duration-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
@ -47,14 +47,14 @@ const ComposerSuggestionList = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("max-h-[160px] overflow-y-auto sm:max-h-[232px]", className)}
|
className={cn("max-h-[144px] overflow-y-auto sm:max-h-[200px]", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
ComposerSuggestionList.displayName = "ComposerSuggestionList";
|
ComposerSuggestionList.displayName = "ComposerSuggestionList";
|
||||||
|
|
||||||
function ComposerSuggestionGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function ComposerSuggestionGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("px-1.5 py-2", className)} {...props} />;
|
return <div className={cn("px-1.5 py-1.5", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComposerSuggestionGroupHeading({
|
function ComposerSuggestionGroupHeading({
|
||||||
|
|
@ -63,7 +63,7 @@ function ComposerSuggestionGroupHeading({
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("px-2.5 py-1.5 text-xs font-bold text-muted-foreground/55", className)}
|
className={cn("px-2 py-1 text-xs font-semibold text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -78,12 +78,12 @@ function ComposerSuggestionHeader({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-2 py-1.5 text-sm font-semibold text-muted-foreground",
|
"flex items-center gap-1.5 px-2 py-1 text-xs font-semibold text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon ? <span className="shrink-0 text-muted-foreground">{icon}</span> : null}
|
{icon ? <span className="shrink-0 text-current [&_svg]:size-3.5">{icon}</span> : null}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -103,7 +103,7 @@ const ComposerSuggestionItem = React.forwardRef<
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto w-full justify-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm font-normal transition-colors",
|
"h-auto w-full justify-start gap-1.5 rounded-md px-2 py-1 text-left text-xs font-normal transition-colors",
|
||||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||||
muted && !selected && "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
muted && !selected && "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
selected && "bg-accent text-accent-foreground",
|
selected && "bg-accent text-accent-foreground",
|
||||||
|
|
@ -111,7 +111,7 @@ const ComposerSuggestionItem = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon ? <span className="shrink-0 text-muted-foreground">{icon}</span> : null}
|
{icon ? <span className="shrink-0 text-current [&_svg]:size-3.5">{icon}</span> : null}
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
));
|
));
|
||||||
|
|
@ -119,7 +119,7 @@ ComposerSuggestionItem.displayName = "ComposerSuggestionItem";
|
||||||
|
|
||||||
function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("my-1 px-3", className)}>
|
<div className={cn("my-0.5 px-2.5", className)}>
|
||||||
<Separator className="bg-popover-border" {...props} />
|
<Separator className="bg-popover-border" {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -134,7 +134,7 @@ function ComposerSuggestionMessage({
|
||||||
<div className="px-1.5 py-1">
|
<div className="px-1.5 py-1">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1.5 text-xs",
|
"px-2 py-1 text-xs",
|
||||||
variant === "destructive" ? "text-destructive" : "text-muted-foreground",
|
variant === "destructive" ? "text-destructive" : "text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
@ -148,22 +148,22 @@ function ComposerSuggestionMessage({
|
||||||
function ComposerSuggestionSkeleton() {
|
function ComposerSuggestionSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="px-1.5 py-1">
|
<div className="px-1.5 py-1">
|
||||||
<div className="px-2.5 py-1.5">
|
<div className="px-2 py-1">
|
||||||
<Skeleton className="h-[16px] w-24" />
|
<Skeleton className="h-3.5 w-20" />
|
||||||
</div>
|
</div>
|
||||||
{["a", "b", "c", "d", "e"].map((id, index) => (
|
{["a", "b", "c", "d", "e"].map((id, index) => (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left",
|
"flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left",
|
||||||
index >= 3 && "hidden sm:flex"
|
index >= 3 && "hidden sm:flex"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
<Skeleton className="size-4" />
|
<Skeleton className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 text-sm">
|
<span className="flex-1 text-xs">
|
||||||
<Skeleton className="h-[20px]" style={{ width: `${60 + ((index * 7) % 30)}%` }} />
|
<Skeleton className="h-4" style={{ width: `${60 + ((index * 7) % 30)}%` }} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import {
|
||||||
Unplug,
|
Unplug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Fragment,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
type UIEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -19,7 +21,6 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type * as React from "react";
|
|
||||||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||||
|
|
@ -61,6 +62,8 @@ interface DocumentMentionPickerProps {
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const MIN_SEARCH_LENGTH = 2;
|
const MIN_SEARCH_LENGTH = 2;
|
||||||
const DEBOUNCE_MS = 100;
|
const DEBOUNCE_MS = 100;
|
||||||
|
const RECENTS_LIMIT = 3;
|
||||||
|
const RECENTS_STORAGE_PREFIX = "surfsense:composer-mention-recents:v1:";
|
||||||
|
|
||||||
type BrowseView =
|
type BrowseView =
|
||||||
| { kind: "root" }
|
| { kind: "root" }
|
||||||
|
|
@ -77,6 +80,89 @@ function isConnectorActive(connector: SearchSourceConnector) {
|
||||||
return connector.is_active !== false;
|
return connector.is_active !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMentionedContextItem(value: unknown): value is MentionedDocumentInfo {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const item = value as Partial<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 === "connector") {
|
||||||
|
return typeof item.connector_type === "string" && typeof item.account_name === "string";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecentsStorageKey(searchSpaceId: number) {
|
||||||
|
return `${RECENTS_STORAGE_PREFIX}${searchSpaceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecentMentions(searchSpaceId: number): MentionedDocumentInfo[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(getRecentsStorageKey(searchSpaceId));
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter(isMentionedContextItem).slice(0, RECENTS_LIMIT);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRecentMentions(searchSpaceId: number, mentions: MentionedDocumentInfo[]) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
getRecentsStorageKey(searchSpaceId),
|
||||||
|
JSON.stringify(mentions.slice(0, RECENTS_LIMIT))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Recents are optional UI state; storage failures should not block mention insertion.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteRecentMention(searchSpaceId: number, mention: MentionedDocumentInfo) {
|
||||||
|
const mentionKey = getMentionDocKey(mention);
|
||||||
|
const next = [
|
||||||
|
mention,
|
||||||
|
...readRecentMentions(searchSpaceId).filter((item) => getMentionDocKey(item) !== mentionKey),
|
||||||
|
].slice(0, RECENTS_LIMIT);
|
||||||
|
writeRecentMentions(searchSpaceId, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMentionIcon(mention: MentionedDocumentInfo) {
|
||||||
|
if (mention.kind === "folder") return <FolderIcon className="size-4" />;
|
||||||
|
if (mention.kind === "connector") {
|
||||||
|
return getConnectorIcon(mention.connector_type, "size-4") ?? <Unplug className="size-4" />;
|
||||||
|
}
|
||||||
|
return getConnectorIcon(mention.document_type, "size-4");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshRecentMention(
|
||||||
|
mention: MentionedDocumentInfo,
|
||||||
|
documents: Pick<Document, "id" | "title" | "document_type">[],
|
||||||
|
folders: { id: number; name: string }[],
|
||||||
|
connectors: SearchSourceConnector[],
|
||||||
|
hasHydratedRecentDocs: boolean
|
||||||
|
): MentionedDocumentInfo | null {
|
||||||
|
if (mention.kind === "doc") {
|
||||||
|
const doc = documents.find(
|
||||||
|
(item) => item.id === mention.id && item.document_type === mention.document_type
|
||||||
|
);
|
||||||
|
if (doc) return makeDocMention(doc);
|
||||||
|
return hasHydratedRecentDocs ? null : mention;
|
||||||
|
}
|
||||||
|
if (mention.kind === "folder") {
|
||||||
|
const folder = folders.find((item) => item.id === mention.id);
|
||||||
|
return folder ? makeFolderMention({ id: folder.id, title: folder.name }) : null;
|
||||||
|
}
|
||||||
|
const connector = connectors.find(
|
||||||
|
(item) => item.id === mention.id && item.connector_type === mention.connector_type
|
||||||
|
);
|
||||||
|
return connector ? makeConnectorMention(connector) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
|
function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
@ -156,6 +242,9 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [recentMentions, setRecentMentions] = useState<MentionedDocumentInfo[]>(() =>
|
||||||
|
readRecentMentions(searchSpaceId)
|
||||||
|
);
|
||||||
|
|
||||||
const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId }));
|
const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId }));
|
||||||
const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom);
|
const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom);
|
||||||
|
|
@ -178,6 +267,10 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
if (hasSearch) setView({ kind: "root" });
|
if (hasSearch) setView({ kind: "root" });
|
||||||
}, [hasSearch]);
|
}, [hasSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRecentMentions(readRecentMentions(searchSpaceId));
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
const titleSearchParams = useMemo(
|
const titleSearchParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
|
|
@ -226,24 +319,24 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage !== 0) return;
|
if (currentPage !== 0) return;
|
||||||
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
||||||
|
|
||||||
if (surfsenseDocs?.items) {
|
if (surfsenseDocs?.items) {
|
||||||
for (const doc of surfsenseDocs.items) {
|
for (const doc of surfsenseDocs.items) {
|
||||||
combinedDocs.push({
|
combinedDocs.push({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
document_type: "SURFSENSE_DOCS",
|
document_type: "SURFSENSE_DOCS",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (titleSearchResults?.items) {
|
if (titleSearchResults?.items) {
|
||||||
combinedDocs.push(...titleSearchResults.items);
|
combinedDocs.push(...titleSearchResults.items);
|
||||||
setHasMore(titleSearchResults.has_more);
|
setHasMore(titleSearchResults.has_more);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
||||||
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
|
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
|
||||||
|
|
||||||
const loadNextPage = useCallback(async () => {
|
const loadNextPage = useCallback(async () => {
|
||||||
|
|
@ -299,6 +392,47 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
() => activeConnectors.map(makeConnectorMention),
|
() => activeConnectors.map(makeConnectorMention),
|
||||||
[activeConnectors]
|
[activeConnectors]
|
||||||
);
|
);
|
||||||
|
const recentDocMentions = useMemo(
|
||||||
|
() => recentMentions.filter((mention) => mention.kind === "doc"),
|
||||||
|
[recentMentions]
|
||||||
|
);
|
||||||
|
const recentDocIdsKey = useMemo(
|
||||||
|
() => recentDocMentions.map((mention) => mention.id).join(","),
|
||||||
|
[recentDocMentions]
|
||||||
|
);
|
||||||
|
const { data: hydratedRecentDocs = [], isFetched: hasHydratedRecentDocs } = useQuery({
|
||||||
|
queryKey: ["composer-mention-recent-docs", searchSpaceId, recentDocIdsKey],
|
||||||
|
queryFn: async () => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
recentDocMentions.map((mention) => documentsApiService.getDocument({ id: mention.id }))
|
||||||
|
);
|
||||||
|
return results
|
||||||
|
.map((result) => (result.status === "fulfilled" ? result.value : null))
|
||||||
|
.filter((doc): doc is Document => doc !== null);
|
||||||
|
},
|
||||||
|
enabled: recentDocMentions.length > 0,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
const recentValidationDocuments = useMemo(
|
||||||
|
() => [...actualDocuments, ...hydratedRecentDocs],
|
||||||
|
[actualDocuments, hydratedRecentDocs]
|
||||||
|
);
|
||||||
|
const visibleRecentMentions = useMemo(
|
||||||
|
() =>
|
||||||
|
recentMentions
|
||||||
|
.map((mention) =>
|
||||||
|
refreshRecentMention(
|
||||||
|
mention,
|
||||||
|
recentValidationDocuments,
|
||||||
|
zeroFolders ?? [],
|
||||||
|
activeConnectors,
|
||||||
|
hasHydratedRecentDocs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((mention): mention is MentionedDocumentInfo => mention !== null)
|
||||||
|
.slice(0, RECENTS_LIMIT),
|
||||||
|
[activeConnectors, hasHydratedRecentDocs, recentMentions, recentValidationDocuments, zeroFolders]
|
||||||
|
);
|
||||||
|
|
||||||
const selectedKeys = useMemo(
|
const selectedKeys = useMemo(
|
||||||
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
||||||
|
|
@ -313,10 +447,22 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
},
|
},
|
||||||
[initialSelectedDocuments, onSelectionChange, onDone]
|
[initialSelectedDocuments, onSelectionChange, onDone]
|
||||||
);
|
);
|
||||||
|
const recentRootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(
|
||||||
|
() =>
|
||||||
|
visibleRecentMentions.map((mention) => ({
|
||||||
|
id: `recent:${getMentionDocKey(mention)}`,
|
||||||
|
label: mention.title,
|
||||||
|
icon: getMentionIcon(mention),
|
||||||
|
type: "item" as const,
|
||||||
|
disabled: selectedKeys.has(getMentionDocKey(mention)),
|
||||||
|
value: { kind: "mention" as const, mention },
|
||||||
|
})),
|
||||||
|
[visibleRecentMentions, selectedKeys]
|
||||||
|
);
|
||||||
|
|
||||||
const rootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(
|
const rootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(
|
||||||
() => {
|
() => {
|
||||||
const nodes: ComposerSuggestionNode<ResourceNodeValue>[] = [];
|
const nodes: ComposerSuggestionNode<ResourceNodeValue>[] = [...recentRootNodes];
|
||||||
if (showSurfsenseDocsRootRef.current) {
|
if (showSurfsenseDocsRootRef.current) {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: "surfsense-docs",
|
id: "surfsense-docs",
|
||||||
|
|
@ -350,7 +496,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
);
|
);
|
||||||
return nodes;
|
return nodes;
|
||||||
},
|
},
|
||||||
[activeConnectors.length]
|
[activeConnectors.length, recentRootNodes]
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
|
const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
|
||||||
|
|
@ -519,10 +665,11 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
onBack: handleBack,
|
onBack: handleBack,
|
||||||
ref,
|
ref,
|
||||||
});
|
});
|
||||||
|
const canLoadMoreDocuments = hasSearch || view.kind === "files-folders";
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e: React.UIEvent<HTMLDivElement>) => {
|
(e: UIEvent<HTMLDivElement>) => {
|
||||||
if (view.kind === "connectors" || view.kind === "connector-type") return;
|
if (!canLoadMoreDocuments) return;
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget;
|
||||||
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
|
|
||||||
|
|
@ -530,7 +677,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
loadNextPage();
|
loadNextPage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hasMore, isLoadingMore, loadNextPage, view.kind]
|
[canLoadMoreDocuments, hasMore, isLoadingMore, loadNextPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const actualLoading =
|
const actualLoading =
|
||||||
|
|
@ -564,15 +711,21 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
{title ? (
|
{title ? (
|
||||||
<>
|
<>
|
||||||
<ComposerSuggestionHeader
|
<ComposerSuggestionHeader
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Back from ${title}`}
|
||||||
|
onClick={handleBack}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="cursor-pointer rounded-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
icon={
|
icon={
|
||||||
<button
|
<span className="-ml-0.5 flex size-4.5 items-center justify-center">
|
||||||
type="button"
|
<ChevronLeft className="size-3.5" />
|
||||||
onClick={handleBack}
|
</span>
|
||||||
aria-label="Back"
|
|
||||||
className="-ml-0.5 flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate">{title}</span>
|
<span className="flex-1 truncate">{title}</span>
|
||||||
|
|
@ -586,31 +739,43 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
{hasSearch ? (
|
{hasSearch ? (
|
||||||
<ComposerSuggestionGroupHeading>Suggested Context</ComposerSuggestionGroupHeading>
|
<ComposerSuggestionGroupHeading>Suggested Context</ComposerSuggestionGroupHeading>
|
||||||
) : null}
|
) : null}
|
||||||
{visibleNodes.map((node, index) => (
|
{!hasSearch && view.kind === "root" && recentRootNodes.length > 0 ? (
|
||||||
<ComposerSuggestionItem
|
<ComposerSuggestionGroupHeading>Recents</ComposerSuggestionGroupHeading>
|
||||||
key={node.id}
|
) : null}
|
||||||
ref={navigator.getItemRef(index)}
|
{visibleNodes.map((node, index) => {
|
||||||
icon={node.icon}
|
const showRecentsSeparator =
|
||||||
selected={index === navigator.highlightedIndex}
|
!hasSearch &&
|
||||||
disabled={node.disabled}
|
view.kind === "root" &&
|
||||||
onClick={() => !node.disabled && handleNodeSelect(node)}
|
recentRootNodes.length > 0 &&
|
||||||
onMouseEnter={() => navigator.setHighlightedIndex(index)}
|
index === recentRootNodes.length;
|
||||||
>
|
return (
|
||||||
<span className="min-w-0 flex-1">
|
<Fragment key={node.id}>
|
||||||
<span className="block truncate text-sm" title={node.label}>
|
{showRecentsSeparator ? <ComposerSuggestionSeparator /> : null}
|
||||||
{node.label}
|
<ComposerSuggestionItem
|
||||||
|
ref={navigator.getItemRef(index)}
|
||||||
|
icon={node.icon}
|
||||||
|
selected={index === navigator.highlightedIndex}
|
||||||
|
disabled={node.disabled}
|
||||||
|
onClick={() => !node.disabled && handleNodeSelect(node)}
|
||||||
|
onMouseEnter={() => navigator.setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xs" title={node.label}>
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
{node.subtitle ? (
|
||||||
|
<span className="block truncate text-[10px] text-muted-foreground">
|
||||||
|
{node.subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{node.subtitle ? (
|
{node.type === "branch" ? (
|
||||||
<span className="block truncate text-[11px] text-muted-foreground">
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
{node.subtitle}
|
) : null}
|
||||||
</span>
|
</ComposerSuggestionItem>
|
||||||
) : null}
|
</Fragment>
|
||||||
</span>
|
);
|
||||||
{node.type === "branch" ? (
|
})}
|
||||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
) : null}
|
|
||||||
</ComposerSuggestionItem>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ComposerSuggestionMessage>
|
<ComposerSuggestionMessage>
|
||||||
|
|
@ -618,7 +783,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
</ComposerSuggestionMessage>
|
</ComposerSuggestionMessage>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoadingMore && (
|
{canLoadMoreDocuments && isLoadingMore && (
|
||||||
<div className="flex items-center justify-center py-2 text-primary">
|
<div className="flex items-center justify-center py-2 text-primary">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -142,12 +142,12 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
||||||
if (el) itemRefs.current.set(index, el);
|
if (el) itemRefs.current.set(index, el);
|
||||||
else itemRefs.current.delete(index);
|
else itemRefs.current.delete(index);
|
||||||
}}
|
}}
|
||||||
icon={<WandSparkles className="size-4" />}
|
icon={<WandSparkles className="size-3.5" />}
|
||||||
selected={index === highlightedIndex}
|
selected={index === highlightedIndex}
|
||||||
onClick={() => handleSelect(index)}
|
onClick={() => handleSelect(index)}
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate text-sm">{action.name}</span>
|
<span className="flex-1 truncate text-xs">{action.name}</span>
|
||||||
</ComposerSuggestionItem>
|
</ComposerSuggestionItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -157,7 +157,7 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
||||||
if (el) itemRefs.current.set(createPromptIndex, el);
|
if (el) itemRefs.current.set(createPromptIndex, el);
|
||||||
else itemRefs.current.delete(createPromptIndex);
|
else itemRefs.current.delete(createPromptIndex);
|
||||||
}}
|
}}
|
||||||
icon={<Plus className="size-4" />}
|
icon={<Plus className="size-3.5" />}
|
||||||
muted
|
muted
|
||||||
selected={highlightedIndex === createPromptIndex}
|
selected={highlightedIndex === createPromptIndex}
|
||||||
onClick={() => handleSelect(createPromptIndex)}
|
onClick={() => handleSelect(createPromptIndex)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue