mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(web): implement composer suggestion popover and integrate with document mention picker
This commit is contained in:
parent
18c66409a0
commit
0d65a2e4e3
4 changed files with 389 additions and 313 deletions
|
|
@ -65,6 +65,7 @@ import {
|
||||||
} from "@/components/assistant-ui/inline-mention-editor";
|
} from "@/components/assistant-ui/inline-mention-editor";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||||
|
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
||||||
import {
|
import {
|
||||||
DocumentMentionPicker,
|
DocumentMentionPicker,
|
||||||
type DocumentMentionPickerRef,
|
type DocumentMentionPickerRef,
|
||||||
|
|
@ -90,6 +91,7 @@ import {
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
|
@ -533,6 +535,11 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
}, [showDocumentPopover]);
|
}, [showDocumentPopover]);
|
||||||
|
|
||||||
|
const handleDocumentPopoverOpenChange = useCallback((open: boolean) => {
|
||||||
|
setShowDocumentPopover(open);
|
||||||
|
if (!open) setMentionQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleActionTrigger = useCallback((query: string) => {
|
const handleActionTrigger = useCallback((query: string) => {
|
||||||
setShowPromptPicker(true);
|
setShowPromptPicker(true);
|
||||||
setActionQuery(query);
|
setActionQuery(query);
|
||||||
|
|
@ -545,6 +552,11 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
}, [showPromptPicker]);
|
}, [showPromptPicker]);
|
||||||
|
|
||||||
|
const handlePromptPickerOpenChange = useCallback((open: boolean) => {
|
||||||
|
setShowPromptPicker(open);
|
||||||
|
if (!open) setActionQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleActionSelect = useCallback(
|
const handleActionSelect = useCallback(
|
||||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||||
let userText = editorRef.current?.getText() ?? "";
|
let userText = editorRef.current?.getText() ?? "";
|
||||||
|
|
@ -723,8 +735,9 @@ const Composer: FC = () => {
|
||||||
currentUserId={currentUser?.id ?? null}
|
currentUserId={currentUser?.id ?? null}
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
/>
|
/>
|
||||||
{showDocumentPopover && (
|
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
|
||||||
<div className="absolute bottom-full left-0 z-[9999] mb-2">
|
<PopoverAnchor className="pointer-events-none absolute inset-0" />
|
||||||
|
<ComposerSuggestionPopoverContent side="top">
|
||||||
<DocumentMentionPicker
|
<DocumentMentionPicker
|
||||||
ref={documentPickerRef}
|
ref={documentPickerRef}
|
||||||
searchSpaceId={Number(search_space_id)}
|
searchSpaceId={Number(search_space_id)}
|
||||||
|
|
@ -736,15 +749,11 @@ const Composer: FC = () => {
|
||||||
initialSelectedDocuments={mentionedDocuments}
|
initialSelectedDocuments={mentionedDocuments}
|
||||||
externalSearch={mentionQuery}
|
externalSearch={mentionQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ComposerSuggestionPopoverContent>
|
||||||
)}
|
</Popover>
|
||||||
{showPromptPicker && (
|
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
|
||||||
<div
|
<PopoverAnchor className="pointer-events-none absolute inset-0" />
|
||||||
className={cn(
|
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
|
||||||
"absolute left-0 z-[9999]",
|
|
||||||
clipboardInitialText ? "top-full mt-2" : "bottom-full mb-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PromptPicker
|
<PromptPicker
|
||||||
ref={promptPickerRef}
|
ref={promptPickerRef}
|
||||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||||
|
|
@ -754,8 +763,8 @@ const Composer: FC = () => {
|
||||||
}}
|
}}
|
||||||
externalSearch={actionQuery}
|
externalSearch={actionQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ComposerSuggestionPopoverContent>
|
||||||
)}
|
</Popover>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
158
surfsense_web/components/new-chat/composer-suggestion-popup.tsx
Normal file
158
surfsense_web/components/new-chat/composer-suggestion-popup.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PopoverContent } from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ComposerSuggestionPopoverContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 8,
|
||||||
|
onOpenAutoFocus,
|
||||||
|
onCloseAutoFocus,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverContent>) {
|
||||||
|
return (
|
||||||
|
<PopoverContent
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenAutoFocus?.(event);
|
||||||
|
}}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onCloseAutoFocus?.(event);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-[280px] overflow-hidden rounded-xl border border-popover-border bg-popover p-0 text-popover-foreground shadow-2xl sm:w-[320px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComposerSuggestionList = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[180px] overflow-y-auto sm:max-h-[280px]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ComposerSuggestionList.displayName = "ComposerSuggestionList";
|
||||||
|
|
||||||
|
function ComposerSuggestionGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("px-2 py-1", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSuggestionGroupHeading({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("px-3 py-2 text-xs font-bold text-muted-foreground/55", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComposerSuggestionItem = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
Omit<React.ComponentProps<typeof Button>, "variant"> & {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
selected?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, children, icon, selected, muted, disabled, ...props }, ref) => (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"h-auto w-full justify-start gap-2 rounded-md px-3 py-2 text-left text-sm font-normal transition-colors",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||||
|
muted && !selected && "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
|
selected && "bg-accent text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? <span className="shrink-0 text-muted-foreground">{icon}</span> : null}
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
));
|
||||||
|
ComposerSuggestionItem.displayName = "ComposerSuggestionItem";
|
||||||
|
|
||||||
|
function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("my-1 px-4", className)}>
|
||||||
|
<Separator className="bg-popover-border" {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSuggestionMessage({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = "muted",
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement> & { variant?: "muted" | "destructive" }) {
|
||||||
|
return (
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-xs",
|
||||||
|
variant === "destructive" ? "text-destructive" : "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSuggestionSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<Skeleton className="h-[16px] w-24" />
|
||||||
|
</div>
|
||||||
|
{["a", "b", "c", "d", "e"].map((id, index) => (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left",
|
||||||
|
index >= 3 && "hidden sm:flex"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">
|
||||||
|
<Skeleton className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-sm">
|
||||||
|
<Skeleton className="h-[20px]" style={{ width: `${60 + ((index * 7) % 30)}%` }} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ComposerSuggestionPopoverContent,
|
||||||
|
ComposerSuggestionList,
|
||||||
|
ComposerSuggestionGroup,
|
||||||
|
ComposerSuggestionGroupHeading,
|
||||||
|
ComposerSuggestionItem,
|
||||||
|
ComposerSuggestionSeparator,
|
||||||
|
ComposerSuggestionMessage,
|
||||||
|
ComposerSuggestionSkeleton,
|
||||||
|
};
|
||||||
|
|
@ -17,13 +17,20 @@ import {
|
||||||
FOLDER_MENTION_DOCUMENT_TYPE,
|
FOLDER_MENTION_DOCUMENT_TYPE,
|
||||||
type MentionedDocumentInfo,
|
type MentionedDocumentInfo,
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
ComposerSuggestionGroup,
|
||||||
|
ComposerSuggestionGroupHeading,
|
||||||
|
ComposerSuggestionItem,
|
||||||
|
ComposerSuggestionList,
|
||||||
|
ComposerSuggestionMessage,
|
||||||
|
ComposerSuggestionSeparator,
|
||||||
|
ComposerSuggestionSkeleton,
|
||||||
|
} from "@/components/new-chat/composer-suggestion-popup";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
|
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { queries } from "@/zero/queries";
|
import { queries } from "@/zero/queries";
|
||||||
|
|
||||||
export interface DocumentMentionPickerRef {
|
export interface DocumentMentionPickerRef {
|
||||||
|
|
@ -427,221 +434,160 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ComposerSuggestionList
|
||||||
className="shadow-2xl rounded-lg overflow-hidden bg-popover text-popover-foreground flex flex-col w-[280px] sm:w-[320px] select-none"
|
ref={scrollContainerRef}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={handleScroll}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{/* Scrollable document list with responsive height */}
|
{actualLoading ? (
|
||||||
<div
|
<ComposerSuggestionSkeleton />
|
||||||
ref={scrollContainerRef}
|
) : actualDocuments.length > 0 || folderMentions.length > 0 ? (
|
||||||
className="max-h-[180px] sm:max-h-[280px] overflow-y-auto"
|
<ComposerSuggestionGroup>
|
||||||
onScroll={handleScroll}
|
{/* SurfSense Documentation */}
|
||||||
>
|
{surfsenseDocsList.length > 0 && (
|
||||||
{actualLoading ? (
|
<>
|
||||||
<div className="py-1 px-2">
|
<ComposerSuggestionGroupHeading>SurfSense Docs</ComposerSuggestionGroupHeading>
|
||||||
<div className="px-3 py-2">
|
{surfsenseDocsList.map((doc) => {
|
||||||
<Skeleton className="h-[16px] w-24" />
|
const mention: MentionedDocumentInfo = {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
kind: "doc",
|
||||||
|
};
|
||||||
|
const docKey = getMentionDocKey(mention);
|
||||||
|
const isAlreadySelected = selectedKeys.has(docKey);
|
||||||
|
const selectableIndex = selectableMentions.findIndex(
|
||||||
|
(m) => getMentionDocKey(m) === docKey
|
||||||
|
);
|
||||||
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerSuggestionItem
|
||||||
|
key={docKey}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el);
|
||||||
|
else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex);
|
||||||
|
}}
|
||||||
|
icon={getConnectorIcon(doc.document_type)}
|
||||||
|
selected={isHighlighted}
|
||||||
|
disabled={isAlreadySelected}
|
||||||
|
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||||
|
setHighlightedIndex(selectableIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-sm" title={doc.title}>
|
||||||
|
{doc.title}
|
||||||
|
</span>
|
||||||
|
</ComposerSuggestionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Documents */}
|
||||||
|
{userDocsList.length > 0 && (
|
||||||
|
<>
|
||||||
|
{surfsenseDocsList.length > 0 && <ComposerSuggestionSeparator className="my-4" />}
|
||||||
|
<ComposerSuggestionGroupHeading>Your Documents</ComposerSuggestionGroupHeading>
|
||||||
|
{userDocsList.map((doc) => {
|
||||||
|
const mention: MentionedDocumentInfo = {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
kind: "doc",
|
||||||
|
};
|
||||||
|
const docKey = getMentionDocKey(mention);
|
||||||
|
const isAlreadySelected = selectedKeys.has(docKey);
|
||||||
|
const selectableIndex = selectableMentions.findIndex(
|
||||||
|
(m) => getMentionDocKey(m) === docKey
|
||||||
|
);
|
||||||
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerSuggestionItem
|
||||||
|
key={docKey}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el);
|
||||||
|
else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex);
|
||||||
|
}}
|
||||||
|
icon={getConnectorIcon(doc.document_type)}
|
||||||
|
selected={isHighlighted}
|
||||||
|
disabled={isAlreadySelected}
|
||||||
|
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||||
|
setHighlightedIndex(selectableIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-sm" title={doc.title}>
|
||||||
|
{doc.title}
|
||||||
|
</span>
|
||||||
|
</ComposerSuggestionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Folders — single source of truth is Zero (same store
|
||||||
|
that powers the documents sidebar). Selecting a
|
||||||
|
folder inserts a folder chip whose path the agent
|
||||||
|
can walk with ``ls`` / ``find_documents``. */}
|
||||||
|
{folderMentions.length > 0 && (
|
||||||
|
<>
|
||||||
|
{(surfsenseDocsList.length > 0 || userDocsList.length > 0) && (
|
||||||
|
<ComposerSuggestionSeparator className="my-4" />
|
||||||
|
)}
|
||||||
|
<ComposerSuggestionGroupHeading>Folders</ComposerSuggestionGroupHeading>
|
||||||
|
{folderMentions.map((folder) => {
|
||||||
|
const folderKey = getMentionDocKey(folder);
|
||||||
|
const isAlreadySelected = selectedKeys.has(folderKey);
|
||||||
|
const selectableIndex = selectableMentions.findIndex(
|
||||||
|
(m) => getMentionDocKey(m) === folderKey
|
||||||
|
);
|
||||||
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerSuggestionItem
|
||||||
|
key={folderKey}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && selectableIndex >= 0) itemRefs.current.set(selectableIndex, el);
|
||||||
|
else if (selectableIndex >= 0) itemRefs.current.delete(selectableIndex);
|
||||||
|
}}
|
||||||
|
icon={<FolderIcon className="size-4" />}
|
||||||
|
selected={isHighlighted}
|
||||||
|
disabled={isAlreadySelected}
|
||||||
|
onClick={() => !isAlreadySelected && handleSelectMention(folder)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||||
|
setHighlightedIndex(selectableIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-sm" title={folder.title}>
|
||||||
|
{folder.title}
|
||||||
|
</span>
|
||||||
|
</ComposerSuggestionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination loading indicator */}
|
||||||
|
{isLoadingMore && (
|
||||||
|
<div className="flex items-center justify-center py-2 text-primary">
|
||||||
|
<Spinner size="sm" />
|
||||||
</div>
|
</div>
|
||||||
{["a", "b", "c", "d", "e"].map((id, i) => (
|
)}
|
||||||
<div
|
</ComposerSuggestionGroup>
|
||||||
key={id}
|
) : (
|
||||||
className={cn(
|
<ComposerSuggestionMessage>No matching documents</ComposerSuggestionMessage>
|
||||||
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
|
)}
|
||||||
i >= 3 && "hidden sm:flex"
|
</ComposerSuggestionList>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0">
|
|
||||||
<Skeleton className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm">
|
|
||||||
<Skeleton className="h-[20px]" style={{ width: `${60 + ((i * 7) % 30)}%` }} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : actualDocuments.length > 0 || folderMentions.length > 0 ? (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
{/* SurfSense Documentation */}
|
|
||||||
{surfsenseDocsList.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
|
||||||
SurfSense Docs
|
|
||||||
</div>
|
|
||||||
{surfsenseDocsList.map((doc) => {
|
|
||||||
const mention: MentionedDocumentInfo = {
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
document_type: doc.document_type,
|
|
||||||
kind: "doc",
|
|
||||||
};
|
|
||||||
const docKey = getMentionDocKey(mention);
|
|
||||||
const isAlreadySelected = selectedKeys.has(docKey);
|
|
||||||
const selectableIndex = selectableMentions.findIndex(
|
|
||||||
(m) => getMentionDocKey(m) === docKey
|
|
||||||
);
|
|
||||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={docKey}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el && selectableIndex >= 0) {
|
|
||||||
itemRefs.current.set(selectableIndex, el);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (!isAlreadySelected && selectableIndex >= 0) {
|
|
||||||
setHighlightedIndex(selectableIndex);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isAlreadySelected}
|
|
||||||
className={cn(
|
|
||||||
"h-auto w-full justify-start gap-2 px-3 py-2 text-left transition-colors",
|
|
||||||
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
|
||||||
isHighlighted && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-muted-foreground text-sm">
|
|
||||||
{getConnectorIcon(doc.document_type)}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm truncate" title={doc.title}>
|
|
||||||
{doc.title}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Documents */}
|
|
||||||
{userDocsList.length > 0 && (
|
|
||||||
<>
|
|
||||||
{surfsenseDocsList.length > 0 && (
|
|
||||||
<div className="mx-2 my-4 border-t border-popover-border" />
|
|
||||||
)}
|
|
||||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
|
||||||
Your Documents
|
|
||||||
</div>
|
|
||||||
{userDocsList.map((doc) => {
|
|
||||||
const mention: MentionedDocumentInfo = {
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
document_type: doc.document_type,
|
|
||||||
kind: "doc",
|
|
||||||
};
|
|
||||||
const docKey = getMentionDocKey(mention);
|
|
||||||
const isAlreadySelected = selectedKeys.has(docKey);
|
|
||||||
const selectableIndex = selectableMentions.findIndex(
|
|
||||||
(m) => getMentionDocKey(m) === docKey
|
|
||||||
);
|
|
||||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={docKey}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el && selectableIndex >= 0) {
|
|
||||||
itemRefs.current.set(selectableIndex, el);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (!isAlreadySelected && selectableIndex >= 0) {
|
|
||||||
setHighlightedIndex(selectableIndex);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isAlreadySelected}
|
|
||||||
className={cn(
|
|
||||||
"h-auto w-full justify-start gap-2 px-3 py-2 text-left transition-colors",
|
|
||||||
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
|
||||||
isHighlighted && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-muted-foreground text-sm">
|
|
||||||
{getConnectorIcon(doc.document_type)}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm truncate" title={doc.title}>
|
|
||||||
{doc.title}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Folders — single source of truth is Zero (same store
|
|
||||||
that powers the documents sidebar). Selecting a
|
|
||||||
folder inserts a folder chip whose path the agent
|
|
||||||
can walk with ``ls`` / ``find_documents``. */}
|
|
||||||
{folderMentions.length > 0 && (
|
|
||||||
<>
|
|
||||||
{(surfsenseDocsList.length > 0 || userDocsList.length > 0) && (
|
|
||||||
<div className="mx-2 my-4 border-t border-popover-border" />
|
|
||||||
)}
|
|
||||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">Folders</div>
|
|
||||||
{folderMentions.map((folder) => {
|
|
||||||
const folderKey = getMentionDocKey(folder);
|
|
||||||
const isAlreadySelected = selectedKeys.has(folderKey);
|
|
||||||
const selectableIndex = selectableMentions.findIndex(
|
|
||||||
(m) => getMentionDocKey(m) === folderKey
|
|
||||||
);
|
|
||||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={folderKey}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el && selectableIndex >= 0) {
|
|
||||||
itemRefs.current.set(selectableIndex, el);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => !isAlreadySelected && handleSelectMention(folder)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (!isAlreadySelected && selectableIndex >= 0) {
|
|
||||||
setHighlightedIndex(selectableIndex);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isAlreadySelected}
|
|
||||||
className={cn(
|
|
||||||
"h-auto w-full justify-start gap-2 px-3 py-2 text-left transition-colors",
|
|
||||||
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
|
||||||
isHighlighted && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-muted-foreground text-sm">
|
|
||||||
<FolderIcon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm truncate" title={folder.title}>
|
|
||||||
{folder.title}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination loading indicator */}
|
|
||||||
{isLoadingMore && (
|
|
||||||
<div className="flex items-center justify-center py-2">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching documents</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,15 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
ComposerSuggestionGroup,
|
||||||
import { cn } from "@/lib/utils";
|
ComposerSuggestionGroupHeading,
|
||||||
|
ComposerSuggestionItem,
|
||||||
|
ComposerSuggestionList,
|
||||||
|
ComposerSuggestionMessage,
|
||||||
|
ComposerSuggestionSeparator,
|
||||||
|
ComposerSuggestionSkeleton,
|
||||||
|
} from "@/components/new-chat/composer-suggestion-popup";
|
||||||
|
|
||||||
export interface PromptPickerRef {
|
export interface PromptPickerRef {
|
||||||
selectHighlighted: () => void;
|
selectHighlighted: () => void;
|
||||||
|
|
@ -119,91 +125,48 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shadow-2xl rounded-lg overflow-hidden bg-popover text-popover-foreground flex flex-col w-[280px] sm:w-[320px] select-none">
|
<ComposerSuggestionList ref={scrollContainerRef}>
|
||||||
<div ref={scrollContainerRef} className="max-h-[180px] sm:max-h-[280px] overflow-y-auto">
|
{isLoading ? (
|
||||||
{isLoading ? (
|
<ComposerSuggestionSkeleton />
|
||||||
<div className="py-1 px-2">
|
) : isError ? (
|
||||||
<div className="px-3 py-2">
|
<ComposerSuggestionMessage variant="destructive">Failed to load prompts</ComposerSuggestionMessage>
|
||||||
<Skeleton className="h-[16px] w-24" />
|
) : filtered.length === 0 ? (
|
||||||
</div>
|
<ComposerSuggestionMessage>No matching prompts</ComposerSuggestionMessage>
|
||||||
{["a", "b", "c", "d", "e"].map((id, i) => (
|
) : (
|
||||||
<div
|
<ComposerSuggestionGroup>
|
||||||
key={id}
|
<ComposerSuggestionGroupHeading>Saved Prompts</ComposerSuggestionGroupHeading>
|
||||||
className={cn(
|
{filtered.map((action, index) => (
|
||||||
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
|
<ComposerSuggestionItem
|
||||||
i >= 3 && "hidden sm:flex"
|
key={action.id}
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0">
|
|
||||||
<Skeleton className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm">
|
|
||||||
<Skeleton className="h-[20px]" style={{ width: `${60 + ((i * 7) % 30)}%` }} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : isError ? (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
|
||||||
Saved Prompts
|
|
||||||
</div>
|
|
||||||
{filtered.map((action, index) => (
|
|
||||||
<Button
|
|
||||||
key={action.id}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) itemRefs.current.set(index, el);
|
|
||||||
else itemRefs.current.delete(index);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleSelect(index)}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
|
||||||
className={cn(
|
|
||||||
"h-auto w-full justify-start gap-2 rounded-md px-3 py-2 text-left text-sm font-normal transition-colors",
|
|
||||||
index === highlightedIndex && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-muted-foreground">
|
|
||||||
<WandSparkles className="size-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm truncate">{action.name}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mx-2 my-1 border-t border-popover-border" />
|
|
||||||
<Button
|
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) itemRefs.current.set(createPromptIndex, el);
|
if (el) itemRefs.current.set(index, el);
|
||||||
else itemRefs.current.delete(createPromptIndex);
|
else itemRefs.current.delete(index);
|
||||||
}}
|
}}
|
||||||
type="button"
|
icon={<WandSparkles className="size-4" />}
|
||||||
variant="ghost"
|
selected={index === highlightedIndex}
|
||||||
onClick={() => handleSelect(createPromptIndex)}
|
onClick={() => handleSelect(index)}
|
||||||
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
className={cn(
|
|
||||||
"h-auto w-full justify-start gap-2 rounded-md px-3 py-2 text-left text-sm font-normal text-muted-foreground transition-colors",
|
|
||||||
highlightedIndex === createPromptIndex
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "hover:text-accent-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="shrink-0">
|
<span className="flex-1 truncate text-sm">{action.name}</span>
|
||||||
<Plus className="size-4" />
|
</ComposerSuggestionItem>
|
||||||
</span>
|
))}
|
||||||
<span>Create prompt</span>
|
|
||||||
</Button>
|
<ComposerSuggestionSeparator />
|
||||||
</div>
|
<ComposerSuggestionItem
|
||||||
)}
|
ref={(el) => {
|
||||||
</div>
|
if (el) itemRefs.current.set(createPromptIndex, el);
|
||||||
</div>
|
else itemRefs.current.delete(createPromptIndex);
|
||||||
|
}}
|
||||||
|
icon={<Plus className="size-4" />}
|
||||||
|
muted
|
||||||
|
selected={highlightedIndex === createPromptIndex}
|
||||||
|
onClick={() => handleSelect(createPromptIndex)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
|
||||||
|
>
|
||||||
|
<span>Create prompt</span>
|
||||||
|
</ComposerSuggestionItem>
|
||||||
|
</ComposerSuggestionGroup>
|
||||||
|
)}
|
||||||
|
</ComposerSuggestionList>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue