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

@ -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,
type MentionedDocumentInfo,
} from "@/atoms/chat/mentioned-documents.atom";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
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 type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
import { queries } from "@/zero/queries";
export interface DocumentMentionPickerRef {
@ -427,221 +434,160 @@ export const DocumentMentionPicker = forwardRef<
);
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}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
role="listbox"
tabIndex={-1}
>
{/* Scrollable document list with responsive height */}
<div
ref={scrollContainerRef}
className="max-h-[180px] sm:max-h-[280px] overflow-y-auto"
onScroll={handleScroll}
>
{actualLoading ? (
<div className="py-1 px-2">
<div className="px-3 py-2">
<Skeleton className="h-[16px] w-24" />
{actualLoading ? (
<ComposerSuggestionSkeleton />
) : actualDocuments.length > 0 || folderMentions.length > 0 ? (
<ComposerSuggestionGroup>
{/* SurfSense Documentation */}
{surfsenseDocsList.length > 0 && (
<>
<ComposerSuggestionGroupHeading>SurfSense Docs</ComposerSuggestionGroupHeading>
{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 (
<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>
{["a", "b", "c", "d", "e"].map((id, i) => (
<div
key={id}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
i >= 3 && "hidden sm:flex"
)}
>
<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>
)}
</ComposerSuggestionGroup>
) : (
<ComposerSuggestionMessage>No matching documents</ComposerSuggestionMessage>
)}
</ComposerSuggestionList>
);
});

View file

@ -15,9 +15,15 @@ import {
} from "react";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import {
ComposerSuggestionGroup,
ComposerSuggestionGroupHeading,
ComposerSuggestionItem,
ComposerSuggestionList,
ComposerSuggestionMessage,
ComposerSuggestionSeparator,
ComposerSuggestionSkeleton,
} from "@/components/new-chat/composer-suggestion-popup";
export interface PromptPickerRef {
selectHighlighted: () => void;
@ -119,91 +125,48 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
);
return (
<div className="shadow-2xl rounded-lg overflow-hidden bg-popover text-popover-foreground flex flex-col w-[280px] sm:w-[320px] select-none">
<div ref={scrollContainerRef} className="max-h-[180px] sm:max-h-[280px] overflow-y-auto">
{isLoading ? (
<div className="py-1 px-2">
<div className="px-3 py-2">
<Skeleton className="h-[16px] w-24" />
</div>
{["a", "b", "c", "d", "e"].map((id, i) => (
<div
key={id}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
i >= 3 && "hidden sm:flex"
)}
>
<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
<ComposerSuggestionList ref={scrollContainerRef}>
{isLoading ? (
<ComposerSuggestionSkeleton />
) : isError ? (
<ComposerSuggestionMessage variant="destructive">Failed to load prompts</ComposerSuggestionMessage>
) : filtered.length === 0 ? (
<ComposerSuggestionMessage>No matching prompts</ComposerSuggestionMessage>
) : (
<ComposerSuggestionGroup>
<ComposerSuggestionGroupHeading>Saved Prompts</ComposerSuggestionGroupHeading>
{filtered.map((action, index) => (
<ComposerSuggestionItem
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(createPromptIndex, el);
else itemRefs.current.delete(createPromptIndex);
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
variant="ghost"
onClick={() => handleSelect(createPromptIndex)}
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
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"
)}
icon={<WandSparkles className="size-4" />}
selected={index === highlightedIndex}
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span className="shrink-0">
<Plus className="size-4" />
</span>
<span>Create prompt</span>
</Button>
</div>
)}
</div>
</div>
<span className="flex-1 truncate text-sm">{action.name}</span>
</ComposerSuggestionItem>
))}
<ComposerSuggestionSeparator />
<ComposerSuggestionItem
ref={(el) => {
if (el) itemRefs.current.set(createPromptIndex, el);
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>
);
});