Merge pull request #1439 from AnishSarkar22/fix/mention-documents

feat: improve composer mentions and connector account selection
This commit is contained in:
Rohan Verma 2026-05-26 13:37:18 -07:00 committed by GitHub
commit 820f541f08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1533 additions and 783 deletions

View file

@ -229,6 +229,20 @@ export const COMPOSIO_CONNECTORS = [
},
] as const;
export const CONNECTOR_DISPLAY_DEFINITIONS = [
...OAUTH_CONNECTORS,
...CRAWLERS,
...OTHER_CONNECTORS,
...COMPOSIO_CONNECTORS,
] as const;
export function getConnectorTitle(connectorType: string): string {
return (
CONNECTOR_DISPLAY_DEFINITIONS.find((connector) => connector.connectorType === connectorType)
?.title ?? connectorType
);
}
// Composio Toolkits (available integrations via Composio)
export const COMPOSIO_TOOLKITS = [
{

View file

@ -1,6 +1,6 @@
"use client";
import { Folder as FolderIcon, X as XIcon } from "lucide-react";
import { Folder as FolderIcon, Plug as PlugIcon, X as XIcon } from "lucide-react";
import type { NodeEntry, TElement } from "platejs";
import type { PlateElementProps } from "platejs/react";
import {
@ -20,31 +20,44 @@ import {
useMemo,
useRef,
} from "react";
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
export type MentionKind = "doc" | "folder";
export type MentionKind = "doc" | "folder" | "connector";
export interface MentionedDocument {
id: number;
title: string;
document_type?: string;
kind: MentionKind;
connector_type?: string;
account_name?: string;
}
/**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``.
* Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE``
* so the dedup key never collides with a doc chip sharing the same id.
*/
export type MentionChipInput = {
id: number;
title: string;
document_type?: string;
kind?: MentionKind;
connector_type?: string;
account_name?: string;
};
export type SuggestionAnchorRect = {
left: number;
top: number;
bottom: number;
};
export type SuggestionTriggerInfo = {
query: string;
anchorRect: SuggestionAnchorRect | null;
};
export interface InlineMentionEditorRef {
@ -62,7 +75,12 @@ export interface InlineMentionEditorRef {
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => void;
removeDocumentChip: (docId: number, docType?: string) => void;
removeDocumentChip: (
docId: number,
docType?: string,
kind?: MentionKind,
connectorType?: string
) => void;
setDocumentChipStatus: (
docId: number,
docType: string | undefined,
@ -73,13 +91,13 @@ export interface InlineMentionEditorRef {
interface InlineMentionEditorProps {
placeholder?: string;
onMentionTrigger?: (query: string) => void;
onMentionTrigger?: (trigger: SuggestionTriggerInfo) => void;
onMentionClose?: () => void;
onActionTrigger?: (query: string) => void;
onActionTrigger?: (trigger: SuggestionTriggerInfo) => void;
onActionClose?: () => void;
onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void;
onDocumentRemove?: (docId: number, docType?: string) => void;
onDocumentRemove?: (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean;
className?: string;
@ -95,6 +113,8 @@ type MentionElementNode = {
document_type?: string;
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */
kind?: MentionKind;
connector_type?: string;
account_name?: string;
statusLabel?: string | null;
statusKind?: MentionStatusKind;
children: [{ text: "" }];
@ -117,7 +137,12 @@ const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
* the X button and Backspace go through the same call site.
*/
type MentionEditorContextValue = {
removeChip: (docId: number, docType: string | undefined) => void;
removeChip: (
docId: number,
docType: string | undefined,
kind: MentionKind | undefined,
connectorType: string | undefined
) => void;
};
const MentionEditorContext = createContext<MentionEditorContextValue | null>(null);
@ -134,6 +159,7 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
: "text-amber-700";
const isFolder = element.kind === "folder";
const isConnector = element.kind === "connector";
const ctx = useContext(MentionEditorContext);
return (
@ -144,24 +170,35 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
{isFolder ? (
<FolderIcon className="h-3 w-3" />
) : isConnector ? (
getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? (
<PlugIcon className="h-3 w-3" />
)
) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
</span>
{ctx ? (
<button
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove mention ${element.title}`}
title={`Remove ${element.title}`}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
ctx.removeChip(element.id, element.document_type);
ctx.removeChip(
element.id,
element.document_type,
element.kind,
element.connector_type
);
}}
className="absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100"
className="absolute inset-0 size-3 rounded-sm p-0 opacity-0 transition-opacity hover:bg-transparent hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-0 group-hover:opacity-100 [&_svg]:size-3"
>
<XIcon className="h-3 w-3" />
</button>
<XIcon />
</Button>
) : null}
</span>
</span>
@ -228,6 +265,8 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
title: node.title,
document_type: node.document_type,
kind,
connector_type: node.connector_type,
account_name: node.account_name,
};
map.set(getMentionDocKey(doc), doc);
}
@ -299,6 +338,36 @@ function scanActiveTrigger(text: string, cursor: number) {
return { triggerChar, query };
}
function rectToAnchor(rect: DOMRect): SuggestionAnchorRect {
return {
left: rect.left,
top: rect.top,
bottom: rect.bottom,
};
}
function getSelectionAnchorRect(root: HTMLElement | null): SuggestionAnchorRect | null {
if (!root || typeof window === "undefined") return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || !selection.anchorNode) return null;
if (!root.contains(selection.anchorNode)) return null;
const range = selection.getRangeAt(0).cloneRange();
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect);
if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
const fallbackRange = range.cloneRange();
fallbackRange.setStart(range.startContainer, range.startOffset - 1);
fallbackRange.setEnd(range.startContainer, range.startOffset);
const fallbackRect = fallbackRange.getClientRects()[0] ?? fallbackRange.getBoundingClientRect();
if (fallbackRect.width > 0 || fallbackRect.height > 0) return rectToAnchor(fallbackRect);
}
return null;
}
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
(
{
@ -360,14 +429,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
return;
}
const triggerInfo: SuggestionTriggerInfo = {
query: trigger.query,
anchorRect: getSelectionAnchorRect(editableRef.current),
};
if (trigger.triggerChar === "@") {
onMentionTrigger?.(trigger.query);
onActionClose?.();
onMentionTrigger?.(triggerInfo);
return;
}
onActionTrigger?.(trigger.query);
onMentionClose?.();
onActionTrigger?.(triggerInfo);
},
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
);
@ -394,14 +468,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const removeTriggerText = options?.removeTriggerText ?? true;
const kind: MentionKind = mention.kind ?? "doc";
const document_type =
mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
const mentionNode: MentionElementNode = {
type: MENTION_TYPE,
id: mention.id,
title: mention.title,
document_type,
document_type: mention.document_type,
kind,
connector_type: mention.connector_type,
account_name: mention.account_name,
children: [{ text: "" }],
};
@ -457,17 +531,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[insertMentionChip]
);
// Remove chip(s) matching (id, document_type). Iterates in
// Remove chip(s) matching the mention identity. Iterates in
// descending path order so removing one entry can't invalidate
// later paths. Chips are deduped today, so this typically runs
// at most once.
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
(docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => {
const match = (n: unknown) => {
if (!n || typeof n !== "object" || !("type" in n)) return false;
const node = n as MentionElementNode;
if (node.type !== MENTION_TYPE) return false;
if (node.id !== docId) return false;
if (kind) {
return (
getMentionDocKey({
id: node.id,
kind: node.kind ?? "doc",
document_type: node.document_type,
connector_type: node.connector_type,
}) ===
getMentionDocKey({
id: docId,
kind,
document_type: docType,
connector_type: connectorType,
})
);
}
return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
};
@ -485,9 +575,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Single removal call site for Backspace and the X button so the
// two can never diverge (e.g. one forgetting to notify the parent).
const removeChip = useCallback(
(docId: number, docType: string | undefined) => {
removeDocumentChip(docId, docType);
onDocumentRemove?.(docId, docType);
(
docId: number,
docType: string | undefined,
kind: MentionKind | undefined,
connectorType: string | undefined
) => {
removeDocumentChip(docId, docType, kind, connectorType);
onDocumentRemove?.(docId, docType, kind, connectorType);
},
[onDocumentRemove, removeDocumentChip]
);
@ -610,7 +705,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (!isMentionNode(prev)) return;
e.preventDefault();
removeChip(prev.id, prev.document_type);
removeChip(prev.id, prev.document_type, prev.kind, prev.connector_type);
},
[editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip]
);

View file

@ -1,6 +1,7 @@
"use client";
import type { MouseEventHandler, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@ -60,13 +61,15 @@ export function MentionChip({
const isInteractive = Boolean(onClick) && !disabled;
const chip = (
<button
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel ?? label}
className={cn(
"inline-flex h-5 items-center gap-1 rounded bg-primary/10 px-1 align-middle text-xs font-bold text-primary/60 leading-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"h-5 gap-1 rounded bg-primary/10 px-1 align-middle text-xs font-bold text-primary/60 leading-none hover:bg-primary/10 hover:text-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isInteractive ? "cursor-pointer" : "cursor-default",
disabled && "opacity-60",
className
@ -74,7 +77,7 @@ export function MentionChip({
>
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
<span className="max-w-[120px] truncate leading-none">{label}</span>
</button>
</Button>
);
if (!tooltip) return chip;

View file

@ -62,13 +62,17 @@ import {
InlineMentionEditor,
type InlineMentionEditorRef,
type MentionedDocument,
type SuggestionAnchorRect,
type SuggestionTriggerInfo,
} from "@/components/assistant-ui/inline-mention-editor";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
import {
DocumentMentionPicker,
promoteRecentMention,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
} from "../new-chat/document-mention-picker";
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@ -90,6 +94,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -110,6 +115,34 @@ import { cn } from "@/lib/utils";
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
type ComposerSuggestionAnchorPoint = {
left: number;
top: number;
};
function ComposerSuggestionAnchor({ point }: { point: ComposerSuggestionAnchorPoint }) {
return (
<PopoverAnchor
className="pointer-events-none fixed size-0"
style={{
left: point.left,
top: point.top,
}}
/>
);
}
function getComposerSuggestionAnchorPoint(
triggerRect: SuggestionAnchorRect | null,
side: "top" | "bottom"
): ComposerSuggestionAnchorPoint | null {
if (!triggerRect) return null;
return {
left: triggerRect.left,
top: side === "bottom" ? triggerRect.bottom : triggerRect.top,
};
}
export const Thread: FC = () => {
return <ThreadContent />;
};
@ -409,6 +442,8 @@ const Composer: FC = () => {
const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState("");
const [suggestionAnchorPoint, setSuggestionAnchorPoint] =
useState<ComposerSuggestionAnchorPoint | null>(null);
const editorRef = useRef<InlineMentionEditorRef>(null);
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
@ -489,6 +524,7 @@ const Composer: FC = () => {
lastSeenSlideoutTickRef.current = slideoutOpenedTick;
setShowDocumentPopover(false);
setMentionQuery("");
setSuggestionAnchorPoint(null);
}, [slideoutOpenedTick]);
// Sync editor text into assistant-ui's composer and mirror the chip
@ -507,44 +543,96 @@ const Composer: FC = () => {
return prev;
}
}
return docs.map<MentionedDocumentInfo>((d) => ({
id: d.id,
title: d.title,
// Atom requires a string; ``"UNKNOWN"`` matches the
// sentinel ``getMentionDocKey`` and the editor's
// match predicates use.
document_type: d.document_type ?? "UNKNOWN",
kind: d.kind,
}));
return docs.map<MentionedDocumentInfo>((d) => {
if (d.kind === "connector") {
return {
id: d.id,
title: d.title,
kind: "connector",
connector_type: d.connector_type ?? "UNKNOWN",
account_name: d.account_name ?? d.title,
};
}
if (d.kind === "folder") {
return {
id: d.id,
title: d.title,
kind: "folder",
};
}
return {
id: d.id,
title: d.title,
document_type: d.document_type ?? "UNKNOWN",
kind: "doc",
};
});
});
},
[aui, setMentionedDocuments]
);
const handleMentionTrigger = useCallback((query: string) => {
const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
const anchorPoint = getComposerSuggestionAnchorPoint(trigger.anchorRect, "top");
if (!anchorPoint) {
setShowDocumentPopover(false);
setMentionQuery("");
setSuggestionAnchorPoint(null);
return;
}
setSuggestionAnchorPoint((current) => current ?? anchorPoint);
setShowDocumentPopover(true);
setMentionQuery(query);
setMentionQuery(trigger.query);
}, []);
const handleMentionClose = useCallback(() => {
if (showDocumentPopover) {
setShowDocumentPopover(false);
setMentionQuery("");
setSuggestionAnchorPoint(null);
}
}, [showDocumentPopover]);
const handleActionTrigger = useCallback((query: string) => {
setShowPromptPicker(true);
setActionQuery(query);
const handleDocumentPopoverOpenChange = useCallback((open: boolean) => {
setShowDocumentPopover(open);
if (!open) {
setMentionQuery("");
setSuggestionAnchorPoint(null);
}
}, []);
const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
const anchorPoint = getComposerSuggestionAnchorPoint(
trigger.anchorRect,
clipboardInitialText ? "bottom" : "top"
);
if (!anchorPoint) {
setShowPromptPicker(false);
setActionQuery("");
setSuggestionAnchorPoint(null);
return;
}
setSuggestionAnchorPoint((current) => current ?? anchorPoint);
setShowPromptPicker(true);
setActionQuery(trigger.query);
}, [clipboardInitialText]);
const handleActionClose = useCallback(() => {
if (showPromptPicker) {
setShowPromptPicker(false);
setActionQuery("");
setSuggestionAnchorPoint(null);
}
}, [showPromptPicker]);
const handlePromptPickerOpenChange = useCallback((open: boolean) => {
setShowPromptPicker(open);
if (!open) {
setActionQuery("");
setSuggestionAnchorPoint(null);
}
}, []);
const handleActionSelect = useCallback(
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
let userText = editorRef.current?.getText() ?? "";
@ -561,6 +649,7 @@ const Composer: FC = () => {
aui.composer().setText(finalPrompt);
setShowPromptPicker(false);
setActionQuery("");
setSuggestionAnchorPoint(null);
},
[actionQuery, aui]
);
@ -576,6 +665,7 @@ const Composer: FC = () => {
aui.composer().setText(finalPrompt);
setShowPromptPicker(false);
setActionQuery("");
setSuggestionAnchorPoint(null);
setClipboardInitialText(undefined);
},
[clipboardInitialText, electronAPI, aui]
@ -604,6 +694,7 @@ const Composer: FC = () => {
e.preventDefault();
setShowPromptPicker(false);
setActionQuery("");
setSuggestionAnchorPoint(null);
return;
}
}
@ -625,8 +716,12 @@ const Composer: FC = () => {
}
if (e.key === "Escape") {
e.preventDefault();
if (documentPickerRef.current?.goBack()) {
return;
}
setShowDocumentPopover(false);
setMentionQuery("");
setSuggestionAnchorPoint(null);
return;
}
}
@ -659,13 +754,14 @@ const Composer: FC = () => {
]);
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
(docId: number, docType?: string, kind?: "doc" | "folder" | "connector", connectorType?: string) => {
setMentionedDocuments((prev) => {
if (!docType) {
// Fallback when chip type is unavailable.
return prev.filter((doc) => doc.id !== docId);
}
const removedKey = getMentionDocKey({ id: docId, document_type: docType });
const removedKey = getMentionDocKey({
id: docId,
document_type: docType,
kind,
connector_type: connectorType,
});
return prev.filter((doc) => getMentionDocKey(doc) !== removedKey);
});
},
@ -673,6 +769,7 @@ const Composer: FC = () => {
);
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
const parsedSearchSpaceId = Number(search_space_id);
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
@ -680,6 +777,9 @@ const Composer: FC = () => {
const key = getMentionDocKey(mention);
if (editorDocKeys.has(key)) continue;
editorRef.current?.insertMentionChip(mention);
if (Number.isFinite(parsedSearchSpaceId)) {
promoteRecentMention(parsedSearchSpaceId, mention);
}
// Track within the loop so a duplicate-in-batch can't double-insert.
editorDocKeys.add(key);
}
@ -687,7 +787,8 @@ const Composer: FC = () => {
// Atom is reconciled by ``handleEditorChange`` via the editor's
// onChange — no second write path here.
setMentionQuery("");
}, []);
setSuggestionAnchorPoint(null);
}, [search_space_id]);
useEffect(() => {
const editor = editorRef.current;
@ -708,7 +809,12 @@ const Composer: FC = () => {
for (const [key, doc] of prevDocsMap) {
if (!nextDocsMap.has(key)) {
editor.removeDocumentChip(doc.id, doc.document_type);
editor.removeDocumentChip(
doc.id,
doc.kind === "doc" ? doc.document_type : undefined,
doc.kind,
doc.kind === "connector" ? doc.connector_type : undefined
);
}
}
@ -723,39 +829,46 @@ const Composer: FC = () => {
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
{showDocumentPopover && (
<div className="absolute bottom-full left-0 z-[9999] mb-2">
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
)}
{showPromptPicker && (
<div
className={cn(
"absolute left-0 z-[9999]",
clipboardInitialText ? "top-full mt-2" : "bottom-full mb-2"
)}
>
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}
/>
</div>
)}
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
{suggestionAnchorPoint ? (
<>
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
<ComposerSuggestionPopoverContent side="top">
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
setSuggestionAnchorPoint(null);
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</ComposerSuggestionPopoverContent>
</>
) : null}
</Popover>
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
{suggestionAnchorPoint ? (
<>
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
setSuggestionAnchorPoint(null);
}}
externalSearch={actionQuery}
/>
</ComposerSuggestionPopoverContent>
</>
) : null}
</Popover>
<div className="flex w-full flex-col">
<div
className={cn(
@ -782,7 +895,7 @@ const Composer: FC = () => {
onDocumentRemove={handleDocumentRemove}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
className="min-h-[24px]"
className="min-h-[24px] **:data-slate-placeholder:font-normal"
/>
</div>
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
@ -964,7 +1077,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<Switch
checked={isWebSearchEnabled}
tabIndex={-1}
className="pointer-events-none h-4 w-7 shrink-0 border [&>span]:h-3 [&>span]:w-3 [&>span[data-state=checked]]:translate-x-3"
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuItem>
)}
@ -1037,9 +1150,10 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
>
<div className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors">
<CollapsibleTrigger asChild>
<button
<Button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start gap-3 p-0 text-left hover:bg-transparent hover:text-inherit"
>
{iconInfo ? (
<Image
@ -1061,7 +1175,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
</button>
</Button>
</CollapsibleTrigger>
<Switch
checked={!allDisabled}
@ -1203,7 +1317,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<Switch
checked={isWebSearchEnabled}
tabIndex={-1}
className="pointer-events-none h-4 w-7 shrink-0 border [&>span]:h-3 [&>span]:w-3 [&>span[data-state=checked]]:translate-x-3"
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuItem>
)}
@ -1253,7 +1367,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<Switch
checked={!isDisabled}
tabIndex={-1}
className="pointer-events-none shrink-0 scale-[0.6]"
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuItem>
);
@ -1305,7 +1419,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0 scale-[0.6]"
className="mr-2 shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
@ -1334,7 +1448,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<Switch
checked={!isDisabled}
tabIndex={-1}
className="pointer-events-none shrink-0 scale-[0.6]"
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuItem>
);
@ -1374,7 +1488,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<Switch
checked={!isDisabled}
tabIndex={-1}
className="pointer-events-none shrink-0 scale-[0.6]"
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuItem>
);

View file

@ -6,7 +6,7 @@ import {
useMessagePartText,
} from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react";
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil, Plug } from "lucide-react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { type FC, useCallback, useState } from "react";
@ -100,8 +100,13 @@ const UserTextPart: FC = () => {
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
}
const isFolder = segment.doc.kind === "folder";
const isConnector = segment.doc.kind === "connector";
const icon = isFolder ? (
<FolderIcon className="size-3.5" />
) : isConnector ? (
getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
<Plug className="size-3.5" />
)
) : (
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
);
@ -110,8 +115,16 @@ const UserTextPart: FC = () => {
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
icon={icon}
label={segment.doc.title}
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
onClick={isFolder ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)}
tooltip={
isFolder
? `Folder: ${segment.doc.title}`
: isConnector
? `Connector account: ${segment.doc.title}`
: segment.doc.title
}
onClick={
isFolder || isConnector ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)
}
className="mx-0.5"
/>
);

View file

@ -2,6 +2,7 @@
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
@ -74,8 +75,9 @@ export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
if (isGoogleAuth) {
return (
<button
<Button
type="button"
variant="ghost"
onClick={handleGoogleLogin}
disabled={isRedirecting}
className={cn(
@ -85,7 +87,7 @@ export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
>
<GoogleLogo className="h-4 w-4" />
<span>Sign In</span>
</button>
</Button>
);
}

View file

@ -190,7 +190,6 @@ export function FolderTreeView({
for (const f of folders) {
const folderMentionKey = getMentionDocKey({
id: f.id,
document_type: "FOLDER",
kind: "folder",
});
states[f.id] = mentionedDocKeys.has(folderMentionKey) ? "all" : "none";

View file

@ -1063,6 +1063,7 @@ function AuthenticatedDocumentsSidebarBase({
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
return sidebarDocs
.filter((doc) => {
if (doc.kind !== "doc") return false;
const fullDoc = treeDocMap.get(doc.id);
if (!fullDoc) return false;
const state = fullDoc.status?.state ?? "ready";
@ -1124,7 +1125,7 @@ function AuthenticatedDocumentsSidebarBase({
try {
await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted");
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== id));
return true;
} catch (e) {
console.error("Error deleting document:", e);
@ -1953,7 +1954,7 @@ function AnonymousDocumentsSidebar({
onEditDocument={() => gate("edit documents")}
onDeleteDocument={async () => {
handleRemoveDoc();
setSidebarDocs((prev) => prev.filter((d) => d.id !== -1));
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1));
return true;
}}
onMoveDocument={() => gate("organize documents")}

View file

@ -0,0 +1,190 @@
"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 = 6,
collisionPadding = 12,
onOpenAutoFocus,
onCloseAutoFocus,
style,
...props
}: React.ComponentProps<typeof PopoverContent>) {
return (
<PopoverContent
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
onOpenAutoFocus={(event) => {
event.preventDefault();
onOpenAutoFocus?.(event);
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
onCloseAutoFocus?.(event);
}}
className={cn(
"w-[232px] select-none 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",
className
)}
style={{ ...style, animation: "none" }}
{...props}
/>
);
}
const ComposerSuggestionList = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("max-h-[144px] overflow-y-auto sm:max-h-[200px]", className)}
{...props}
/>
));
ComposerSuggestionList.displayName = "ComposerSuggestionList";
function ComposerSuggestionGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-1.5 py-1.5", className)} {...props} />;
}
function ComposerSuggestionGroupHeading({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("px-2 py-1 text-xs font-semibold text-muted-foreground", className)}
{...props}
/>
);
}
function ComposerSuggestionHeader({
className,
icon,
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { icon?: React.ReactNode }) {
return (
<div
className={cn(
"flex items-center gap-1.5 px-2 py-1 text-xs font-semibold text-muted-foreground",
className
)}
{...props}
>
{icon ? <span className="shrink-0 text-current [&_svg]:size-3.5">{icon}</span> : null}
{children}
</div>
);
}
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-1.5 rounded-md px-2 py-1 text-left text-xs 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-current [&_svg]:size-3.5">{icon}</span> : null}
{children}
</Button>
));
ComposerSuggestionItem.displayName = "ComposerSuggestionItem";
function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<div className={cn("my-0.5 px-2.5", className)}>
<Separator className="bg-popover-border" {...props} />
</div>
);
}
function ComposerSuggestionMessage({
className,
children,
variant = "muted",
}: React.HTMLAttributes<HTMLParagraphElement> & { variant?: "muted" | "destructive" }) {
return (
<div className="px-1.5 py-1">
<p
className={cn(
"px-2 py-1 text-xs",
variant === "destructive" ? "text-destructive" : "text-muted-foreground",
className
)}
>
{children}
</p>
</div>
);
}
function ComposerSuggestionSkeleton({
rows = 5,
mobileRows = 3,
}: {
rows?: number;
mobileRows?: number;
}) {
return (
<div className="px-1.5 py-1">
<div className="px-2 py-1">
<Skeleton className="h-3.5 w-20" />
</div>
{Array.from({ length: rows }, (_, index) => `skeleton-row-${index}`).map((id, index) => (
<div
key={id}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left",
index >= mobileRows && "hidden sm:flex"
)}
>
<span className="shrink-0">
<Skeleton className="size-3.5" />
</span>
<span className="flex-1 text-xs">
<Skeleton className="h-4" style={{ width: `${60 + ((index * 7) % 30)}%` }} />
</span>
</div>
))}
</div>
);
}
export {
ComposerSuggestionPopoverContent,
ComposerSuggestionList,
ComposerSuggestionGroup,
ComposerSuggestionGroupHeading,
ComposerSuggestionHeader,
ComposerSuggestionItem,
ComposerSuggestionSeparator,
ComposerSuggestionMessage,
ComposerSuggestionSkeleton,
};

File diff suppressed because it is too large Load diff

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 rows={8} mobileRows={8} />
) : 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-3.5" />}
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-xs">{action.name}</span>
</ComposerSuggestionItem>
))}
<ComposerSuggestionSeparator />
<ComposerSuggestionItem
ref={(el) => {
if (el) itemRefs.current.set(createPromptIndex, el);
else itemRefs.current.delete(createPromptIndex);
}}
icon={<Plus className="size-3.5" />}
muted
selected={highlightedIndex === createPromptIndex}
onClick={() => handleSelect(createPromptIndex)}
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
>
<span>Create prompt</span>
</ComposerSuggestionItem>
</ComposerSuggestionGroup>
)}
</ComposerSuggestionList>
);
});

View file

@ -0,0 +1,120 @@
"use client";
import type * as React from "react";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
export type ComposerSuggestionNode<TValue> = {
id: string;
label: string;
subtitle?: string;
icon?: React.ReactNode;
keywords?: string[];
type: "branch" | "item" | "action";
value?: TValue;
disabled?: boolean;
};
export type ComposerSuggestionNavigatorRef = {
selectHighlighted: () => void;
moveUp: () => void;
moveDown: () => void;
goBack: () => boolean;
};
export type ComposerSuggestionNavigatorOptions<TValue> = {
nodes: ComposerSuggestionNode<TValue>[];
onSelect: (node: ComposerSuggestionNode<TValue>) => void;
onBack?: () => boolean;
ref?: React.Ref<ComposerSuggestionNavigatorRef>;
};
export function useComposerSuggestionNavigator<TValue>({
nodes,
onSelect,
onBack,
ref,
}: ComposerSuggestionNavigatorOptions<TValue>) {
const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const nodesKey = useMemo(() => nodes.map((node) => node.id).join("\u0000"), [nodes]);
const previousNodesKeyRef = useRef<string | null>(null);
// Reset keyboard focus when the caller swaps the visible node set.
useEffect(() => {
if (previousNodesKeyRef.current === nodesKey) return;
previousNodesKeyRef.current = nodesKey;
setHighlightedIndex(0);
itemRefs.current.clear();
}, [nodesKey]);
useEffect(() => {
if (!shouldScrollRef.current) return;
shouldScrollRef.current = false;
const rafId = requestAnimationFrame(() => {
const item = itemRefs.current.get(highlightedIndex);
const container = scrollContainerRef.current;
if (!item || !container) return;
const itemRect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
item.scrollIntoView({ block: "nearest" });
}
});
return () => cancelAnimationFrame(rafId);
}, [highlightedIndex]);
const moveUp = useCallback(() => {
if (nodes.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : nodes.length - 1));
}, [nodes.length]);
const moveDown = useCallback(() => {
if (nodes.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < nodes.length - 1 ? prev + 1 : 0));
}, [nodes.length]);
const selectHighlighted = useCallback(() => {
const node = nodes[highlightedIndex];
if (!node || node.disabled) return;
onSelect(node);
}, [highlightedIndex, nodes, onSelect]);
const goBack = useCallback(() => onBack?.() ?? false, [onBack]);
useImperativeHandle(
ref,
() => ({
selectHighlighted,
moveUp,
moveDown,
goBack,
}),
[goBack, moveDown, moveUp, selectHighlighted]
);
const getItemRef = useCallback(
(index: number) => (el: HTMLButtonElement | null) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
},
[]
);
return {
highlightedIndex,
setHighlightedIndex,
scrollContainerRef,
getItemRef,
moveUp,
moveDown,
selectHighlighted,
goBack,
};
}