feat(web): implement composer suggestion popover and integrate with document mention picker

This commit is contained in:
Anish Sarkar 2026-05-26 13:37:59 +05:30
parent 18c66409a0
commit 0d65a2e4e3
4 changed files with 389 additions and 313 deletions

View file

@ -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(

View 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,
};

View file

@ -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>
); );
}); });

View file

@ -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>
); );
}); });