From 1cb8313d999dc27d6470398b4ca7912cbbc4f9fe Mon Sep 17 00:00:00 2001 From: Rana Faraz Date: Wed, 15 Jul 2026 16:47:09 +0500 Subject: [PATCH] feat: add prompt input mentions and slash commands --- .changeset/prompt-input-suggestions.md | 5 + .../components/(chatbot)/prompt-input.mdx | 210 ++++ .../elements/__tests__/prompt-input.test.tsx | 669 ++++++++++- packages/elements/src/prompt-input.tsx | 1054 ++++++++++++++++- packages/examples/src/prompt-input.tsx | 236 +++- skills/ai-elements/references/prompt-input.md | 125 ++ skills/ai-elements/scripts/prompt-input.tsx | 236 +++- 7 files changed, 2376 insertions(+), 159 deletions(-) create mode 100644 .changeset/prompt-input-suggestions.md diff --git a/.changeset/prompt-input-suggestions.md b/.changeset/prompt-input-suggestions.md new file mode 100644 index 00000000..2d320161 --- /dev/null +++ b/.changeset/prompt-input-suggestions.md @@ -0,0 +1,5 @@ +--- +"ai-elements": patch +--- + +Add contextual mention and slash-command suggestions to PromptInput. diff --git a/apps/docs/content/components/(chatbot)/prompt-input.mdx b/apps/docs/content/components/(chatbot)/prompt-input.mdx index ad2c9f5b..b865a81f 100644 --- a/apps/docs/content/components/(chatbot)/prompt-input.mdx +++ b/apps/docs/content/components/(chatbot)/prompt-input.mdx @@ -237,11 +237,68 @@ export async function POST(req: Request) { } ``` +## Mentions and slash commands + +Wrap the prompt in `PromptInputSuggestions` to open a keyboard-accessible suggestion list when the user types a configured trigger. The hook exposes the active trigger and query so you can filter local or remote results. + +Suggestions replace text in the existing textarea, so the prompt's plain-text submission contract remains unchanged. + +```tsx +const files = [ + "packages/elements/src/prompt-input.tsx", + "packages/elements/src/message.tsx", +]; +const commands = ["search", "summarize", "explain"]; +const triggers = [{ trigger: "@" }, { trigger: "/", startOfLine: true }]; + +const SuggestionMenu = () => { + const { match } = usePromptInputSuggestions(); + + if (!match) { + return null; + } + + const items = match.trigger === "@" ? files : commands; + const filteredItems = items.filter((item) => + item.toLowerCase().includes(match.query.toLowerCase()) + ); + + return ( + + {filteredItems.length > 0 ? ( + filteredItems.map((item) => ( + + {match.trigger} + {item} + + )) + ) : ( + + No results found. + + )} + + ); +}; + + + + + + + + +; +``` + +By default, selecting an item replaces the active query with its trigger and value followed by a space. Use `replaceWith` or `onSelect` on `PromptInputSuggestionItem` when you need custom insertion or selection behavior. + ## Features - Auto-resizing textarea that adjusts height based on content - File attachment support with drag-and-drop - Built-in screenshot capture action +- Contextual `@` mentions and `/` slash-command suggestions - Image preview for image attachments - Configurable file constraints (max files, max size, accepted types) - Automatic submit button icons based on status @@ -330,6 +387,142 @@ Buttons can display tooltips with optional keyboard shortcut hints. Hover over t }} /> +### `` + +Provides suggestion state and connects a descendant `PromptInputTextarea` to the suggestion content. + + void", + }, + children: { + description: "The prompt input and suggestion content.", + type: "React.ReactNode", + }, + }} +/> + +#### `PromptInputSuggestionTrigger` + + + +### `` + +Portal-backed listbox positioned relative to the prompt textarea. + +", + }, + }} +/> + +### `` + +Keyboard- and pointer-selectable suggestion option. The default replacement is `{trigger}{value} `. + + void", + }, + "...props": { + description: "Any other props are spread to the underlying button.", + type: "React button props except onSelect and value", + }, + }} +/> + +### `` + +", + }, + }} +/> + ### `` +): void => { + if (event.key === "ArrowDown") { + event.preventDefault(); + } +}; + +interface FilteredSuggestionMenuProps { + clearIsActionOnly?: boolean; + onClear?: (details: PromptInputSuggestionSelectDetails) => void; +} + +const FilteredSuggestionMenu = ({ + clearIsActionOnly = false, + onClear, +}: FilteredSuggestionMenuProps) => { + const { match } = usePromptInputSuggestions(); + const suggestions = + match?.trigger === "@" ? MENTION_SUGGESTIONS : COMMAND_SUGGESTIONS; + const query = match?.query.toLowerCase() ?? ""; + const filteredSuggestions = suggestions.filter((suggestion) => + suggestion.toLowerCase().includes(query) + ); + + return ( + + + {match ? `${match.trigger}:${match.query}` : ""} + + {filteredSuggestions.map((suggestion) => ( + + {suggestion} + + ))} + + ); +}; + +const MidStringSuggestionMenu = () => ( + + + Ada + + +); + +const PromptInputProviderValue = () => { + const controller = usePromptInputController(); + return ( + {controller.textInput.value} + ); +}; + +const PromptInputSuggestionState = () => { + const { activeValue, match, open } = usePromptInputSuggestions(); + const openState = open ? "open" : "closed"; + return ( + + {`${openState}:${match?.query ?? "none"}:${activeValue ?? "none"}`} + + ); +}; + +const ConditionalSuggestionTextarea = ({ + onSubmit, +}: { + onSubmit: () => void; +}) => { + const [showTextarea, setShowTextarea] = React.useState(true); + + return ( + <> + + + + + {showTextarea ? : null} + + + + + + ); +}; // Backwards-compatibility aliases for tests (these components were moved to attachment.tsx) const PromptInputAttachment = ({ @@ -759,6 +868,564 @@ describe("promptInputTextarea", () => { }); }); +describe("promptInputSuggestions", () => { + it("matches and filters @ mentions and / commands from the active query", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ); + await user.type(textarea, "Ask @ad"); + + const mentionListbox = await screen.findByRole("listbox"); + expect(mentionListbox).toBeInTheDocument(); + expect(screen.getByTestId("suggestion-query")).toHaveTextContent("@:ad"); + expect(screen.getByRole("option", { name: "Ada" })).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: "Alan" }) + ).not.toBeInTheDocument(); + + await user.clear(textarea); + await user.type(textarea, "Please /sum"); + + const commandListbox = await screen.findByRole("listbox"); + expect(commandListbox).toBeInTheDocument(); + expect(screen.getByTestId("suggestion-query")).toHaveTextContent("/:sum"); + expect( + screen.getByRole("option", { name: "summarize" }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: "clear" }) + ).not.toBeInTheDocument(); + }); + + it("does not activate mentions inside email addresses or commands inside URLs", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ); + await user.type(textarea, "user@example.com"); + + expect(textarea).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + + await user.clear(textarea); + await user.type(textarea, "https://example.com/docs"); + + expect(textarea).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("navigates options with active-descendant and selects before submitting", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ) as HTMLTextAreaElement; + await user.type(textarea, "@"); + + const ada = await screen.findByRole("option", { name: "Ada" }); + const alan = screen.getByRole("option", { name: "Alan" }); + await vi.waitFor(() => { + expect(textarea).toHaveAttribute("aria-activedescendant", ada.id); + }); + + await user.keyboard("{ArrowUp}"); + + expect(textarea).toHaveAttribute("aria-activedescendant", alan.id); + expect(alan).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(textarea).toHaveValue("@Alan "); + }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("keeps an escaped match dismissed until a new trigger context starts", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ); + await user.type(textarea, "@a"); + const initialListbox = await screen.findByRole("listbox"); + expect(initialListbox).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + expect(textarea).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + + await user.type(textarea, "d"); + expect(textarea).toHaveAttribute("aria-expanded", "false"); + + await user.type(textarea, " @"); + const nextListbox = await screen.findByRole("listbox"); + expect(nextListbox).toBeInTheDocument(); + expect(textarea).toHaveAttribute("aria-expanded", "true"); + }); + + it("preserves the suffix and textarea focus when a mid-string item is clicked", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ) as HTMLTextAreaElement; + textarea.focus(); + textarea.setSelectionRange(7, 7); + fireEvent.select(textarea); + + const option = await screen.findByRole("option", { name: "Ada" }); + await user.click(option); + + expect(textarea).toHaveValue("Ask @Ada about this"); + expect(document.activeElement).toBe(textarea); + }); + + it("runs action-only items without replacing the active query", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const onClear = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ) as HTMLTextAreaElement; + await user.type(textarea, "/cl"); + await user.click(await screen.findByRole("option", { name: "clear" })); + + expect(textarea).toHaveValue("/cl"); + expect(onClear).toHaveBeenCalledWith( + expect.objectContaining({ + match: expect.objectContaining({ query: "cl", trigger: "/" }), + value: "clear", + }) + ); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("updates PromptInputProvider state when a suggestion replaces text", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ) as HTMLTextAreaElement; + await user.type(textarea, "@ad"); + await user.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(textarea).toHaveValue("@Ada "); + expect(screen.getByTestId("provider-value").textContent).toBe("@Ada "); + }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("waits for IME composition to end before opening suggestions", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const onChange = vi.fn(); + const onCompositionEnd = vi.fn(); + const onCompositionStart = vi.fn(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ) as HTMLTextAreaElement; + textarea.focus(); + fireEvent.compositionStart(textarea); + fireEvent.input(textarea, { target: { value: "@a" } }); + + expect(onCompositionStart).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalled(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + + fireEvent.compositionEnd(textarea); + + expect(onCompositionEnd).toHaveBeenCalledOnce(); + const listbox = await screen.findByRole("listbox"); + expect(listbox).toBeInTheDocument(); + }); + + it("runs external key handlers before internal suggestion navigation", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const onSelect = vi.fn(); + const onKeyDown = vi.fn(preventArrowDown); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ); + await user.type(textarea, "@"); + const ada = await screen.findByRole("option", { name: "Ada" }); + await vi.waitFor(() => { + expect(textarea).toHaveAttribute("aria-activedescendant", ada.id); + }); + onKeyDown.mockClear(); + + await user.keyboard("{ArrowDown}"); + + expect(onKeyDown).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalled(); + expect(textarea).toHaveAttribute("aria-activedescendant", ada.id); + }); + + it("exposes the textarea as a combobox while suggestions are enabled", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + + render( + + + + + + + + + ); + + const textarea = screen.getByRole("combobox"); + expect(textarea).toHaveAttribute("aria-autocomplete", "list"); + expect(textarea).toHaveAttribute("data-slot", "input-group-control"); + }); + + it("keeps the same match open when the caret moves inside the textarea", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ) as HTMLTextAreaElement; + await user.type(textarea, "@a"); + const listbox = await screen.findByRole("listbox"); + expect(listbox).toBeInTheDocument(); + + await user.pointer({ keys: "[MouseLeft>]", target: textarea }); + textarea.setSelectionRange(1, 1); + fireEvent.select(textarea); + await user.pointer({ keys: "[/MouseLeft]", target: textarea }); + + expect(textarea).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.getByTestId("suggestion-query")).toHaveTextContent("@:"); + }); + + it("keeps a consumer-provided option id in sync with active-descendant", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + + Ada + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ); + await user.type(textarea, "@"); + const option = await screen.findByRole("option", { name: "Ada" }); + + await vi.waitFor(() => { + expect(textarea).toHaveAttribute("aria-activedescendant", option.id); + }); + expect(option).toHaveAttribute("id", "custom-option-id"); + }); + + it("scrolls the keyboard-selected option into view", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const scrollIntoView = vi + .spyOn(Element.prototype, "scrollIntoView") + .mockImplementation(vi.fn()); + const user = userEvent.setup(); + + render( + + + + + + + + + ); + + const textarea = screen.getByPlaceholderText( + "What would you like to know?" + ); + await user.type(textarea, "@"); + const alan = await screen.findByRole("option", { name: "Alan" }); + scrollIntoView.mockClear(); + + await user.keyboard("{ArrowDown}"); + + expect(alan).toHaveAttribute("aria-selected", "true"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); + }); + + it("resets suggestions when a conditional textarea unmounts", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByRole("combobox"); + await user.type(textarea, "@a"); + expect(screen.getByTestId("suggestion-state")).toHaveTextContent( + "open:a:none" + ); + + await user.click(screen.getByRole("button", { name: "Hide textarea" })); + + await vi.waitFor(() => { + expect(screen.getByTestId("suggestion-state")).toHaveTextContent( + "closed:none:none" + ); + }); + }); + + it("keeps a stable active item id while its value changes", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render( + + + + + + + + Ada + + + + + + ); + + const textarea = screen.getByRole("combobox"); + await user.type(textarea, "@"); + const option = await screen.findByRole("option", { name: "Ada" }); + await vi.waitFor(() => { + expect(textarea).toHaveAttribute("aria-activedescendant", option.id); + expect(screen.getByTestId("suggestion-state")).toHaveTextContent( + "open::Ada" + ); + }); + + rerender( + + + + + + + + Grace + + + + + + ); + + await vi.waitFor(() => { + expect(textarea).toHaveAttribute( + "aria-activedescendant", + "stable-option" + ); + expect(screen.getByTestId("suggestion-state")).toHaveTextContent( + "open::Grace" + ); + }); + expect(screen.getByRole("option", { name: "Grace" })).toHaveAttribute( + "aria-selected", + "true" + ); + }); + + it("lets onSelect replacement override the default insertion", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const onSelect = vi.fn((details: PromptInputSuggestionSelectDetails) => { + details.replace("@Grace "); + }); + const user = userEvent.setup(); + + render( + + + + + + + Ada + + + + + + ); + + const textarea = screen.getByRole("combobox"); + await user.type(textarea, "@a"); + await user.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(textarea).toHaveValue("@Grace "); + }); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); + describe("promptInputTools", () => { it("renders tools", () => { setupPromptInputTests(); diff --git a/packages/elements/src/prompt-input.tsx b/packages/elements/src/prompt-input.tsx index 412c846d..8a4a4a6d 100644 --- a/packages/elements/src/prompt-input.tsx +++ b/packages/elements/src/prompt-input.tsx @@ -26,6 +26,11 @@ import { InputGroupButton, InputGroupTextarea, } from "@repo/shadcn-ui/components/ui/input-group"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@repo/shadcn-ui/components/ui/popover"; import { Select, SelectContent, @@ -51,16 +56,21 @@ import { } from "lucide-react"; import { nanoid } from "nanoid"; import type { - ChangeEvent, ChangeEventHandler, ClipboardEventHandler, ComponentProps, + CompositionEventHandler, FormEvent, FormEventHandler, + FocusEventHandler, HTMLAttributes, KeyboardEventHandler, + MouseEventHandler, + PointerEventHandler, PropsWithChildren, + ReactEventHandler, ReactNode, + Ref, RefObject, } from "react"; import { @@ -69,6 +79,8 @@ import { useCallback, useContext, useEffect, + useId, + useLayoutEffect, useMemo, useRef, useState, @@ -78,6 +90,19 @@ import { // Helpers // ============================================================================ +const DEFAULT_SUGGESTION_PREFIXES = [" ", "\n", "\t"] as const; + +const setRefValue = (ref: Ref | undefined, value: T | null): void => { + if (typeof ref === "function") { + ref(value); + return; + } + + if (ref) { + ref.current = value; + } +}; + const convertBlobUrlToDataUrl = async (url: string): Promise => { try { const response = await fetch(url); @@ -408,6 +433,822 @@ export const usePromptInputReferencedSources = () => { return ctx; }; +// ============================================================================ +// Suggestions +// ============================================================================ + +const SUGGESTION_WHITESPACE_REGEX = /\s/u; +const SUGGESTION_LINE_BREAK_REGEX = /[\r\n]/u; + +export interface PromptInputSuggestionRange { + start: number; + end: number; +} + +export interface PromptInputSuggestionMatch { + trigger: string; + query: string; + range: PromptInputSuggestionRange; +} + +export interface PromptInputSuggestionTrigger { + trigger: string; + allowSpaces?: boolean; + allowedPrefixes?: readonly string[] | null; + maxQueryLength?: number; + minQueryLength?: number; + startOfLine?: boolean; +} + +export interface PromptInputSuggestionSelectDetails { + close: () => void; + match: PromptInputSuggestionMatch; + replace: (text: string) => void; + value: string; +} + +export interface PromptInputSuggestionsContextValue { + activeValue: string | null; + close: () => void; + match: PromptInputSuggestionMatch | null; + open: boolean; + replace: (text: string) => void; +} + +export type PromptInputSuggestionsProps = PropsWithChildren<{ + onMatchChange?: (match: PromptInputSuggestionMatch | null) => void; + onOpenChange?: (open: boolean) => void; + triggers: readonly PromptInputSuggestionTrigger[]; +}>; + +interface RegisteredSuggestionItem { + disabled: boolean; + element: RefObject; + id: string; + select: () => void; + value: string; +} + +interface SuggestionItemUpdate { + disabled: boolean; + value: string; +} + +interface PromptInputSuggestionsInternalContext extends PromptInputSuggestionsContextValue { + activeId: string | null; + handleKeyDown: KeyboardEventHandler; + isTextareaTarget: (target: EventTarget | null) => boolean; + listboxId: string; + registerItem: (item: RegisteredSuggestionItem) => () => void; + reset: () => void; + setActiveId: (id: string) => void; + setFocused: (focused: boolean) => void; + setTextarea: (textarea: HTMLTextAreaElement | null) => void; + updateItem: (id: string, update: SuggestionItemUpdate) => void; + updateMatch: ( + value: string, + selectionStart: number | null, + selectionEnd: number | null + ) => void; +} + +const PromptInputSuggestionsContext = + createContext(null); + +const usePromptInputSuggestionsInternal = () => + useContext(PromptInputSuggestionsContext); + +const usePromptInputSuggestionsContext = () => { + const context = usePromptInputSuggestionsInternal(); + if (!context) { + throw new Error( + "usePromptInputSuggestions must be used within PromptInputSuggestions" + ); + } + return context; +}; + +export const usePromptInputSuggestions = + (): PromptInputSuggestionsContextValue => usePromptInputSuggestionsContext(); + +const isSameSuggestionMatch = ( + first: PromptInputSuggestionMatch | null, + second: PromptInputSuggestionMatch | null +): boolean => + first?.trigger === second?.trigger && + first?.query === second?.query && + first?.range.start === second?.range.start && + first?.range.end === second?.range.end; + +const findSuggestionMatch = ( + value: string, + selectionStart: number | null, + selectionEnd: number | null, + triggers: readonly PromptInputSuggestionTrigger[] +): PromptInputSuggestionMatch | null => { + if ( + selectionStart === null || + selectionEnd === null || + selectionStart !== selectionEnd + ) { + return null; + } + + const valueBeforeCaret = value.slice(0, selectionStart); + let closestMatch: PromptInputSuggestionMatch | null = null; + + for (const configuration of triggers) { + const { trigger } = configuration; + if (trigger.length === 0) { + continue; + } + + const triggerIndex = valueBeforeCaret.lastIndexOf(trigger); + if (triggerIndex === -1) { + continue; + } + + const previousCharacter = valueBeforeCaret.at(triggerIndex - 1); + const isAtStart = triggerIndex === 0; + const hasAllowedPrefix = + configuration.allowedPrefixes === null || + isAtStart || + (previousCharacter !== undefined && + (configuration.allowedPrefixes ?? DEFAULT_SUGGESTION_PREFIXES).includes( + previousCharacter + )); + const isAtLineStart = isAtStart || previousCharacter === "\n"; + + if ( + (configuration.startOfLine ? !isAtLineStart : !hasAllowedPrefix) || + (closestMatch && triggerIndex < closestMatch.range.start) + ) { + continue; + } + + const query = valueBeforeCaret.slice(triggerIndex + trigger.length); + const containsInvalidWhitespace = configuration.allowSpaces + ? SUGGESTION_LINE_BREAK_REGEX.test(query) + : SUGGESTION_WHITESPACE_REGEX.test(query); + const minQueryLength = configuration.minQueryLength ?? 0; + + if ( + containsInvalidWhitespace || + query.length < minQueryLength || + (configuration.maxQueryLength !== undefined && + query.length > configuration.maxQueryLength) + ) { + continue; + } + + closestMatch = { + query, + range: { + end: selectionStart, + start: triggerIndex, + }, + trigger, + }; + } + + return closestMatch; +}; + +const sortSuggestionItems = ( + items: RegisteredSuggestionItem[] +): RegisteredSuggestionItem[] => + items.sort((first, second) => { + const firstElement = first.element.current; + const secondElement = second.element.current; + if (!(firstElement && secondElement)) { + return 0; + } + + const position = firstElement.compareDocumentPosition(secondElement); + return position === Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + +export const PromptInputSuggestions = ({ + children, + onMatchChange, + onOpenChange, + triggers, +}: PromptInputSuggestionsProps) => { + const listboxId = useId(); + const formRef = useRef(null); + const itemsRef = useRef(new Map()); + const matchRef = useRef(null); + const textareaRef = useRef(null); + const [activeItem, setActiveItem] = useState<{ + id: string; + value: string; + } | null>(null); + const [dismissedMatch, setDismissedMatch] = useState<{ + start: number; + trigger: string; + } | null>(null); + const [focused, setFocused] = useState(false); + const [match, setMatch] = useState(null); + const activeId = activeItem?.id ?? null; + const activeValue = activeItem?.value ?? null; + + const activateItem = useCallback((item: RegisteredSuggestionItem) => { + setActiveItem((currentActiveItem) => + currentActiveItem?.id === item.id && + currentActiveItem.value === item.value + ? currentActiveItem + : { id: item.id, value: item.value } + ); + }, []); + + const open = Boolean( + focused && + match && + !( + dismissedMatch?.start === match.range.start && + dismissedMatch.trigger === match.trigger + ) + ); + + const getEnabledItems = useCallback( + () => + sortSuggestionItems( + [...itemsRef.current.values()].filter( + (item) => !item.disabled && item.element.current + ) + ), + [] + ); + + const reset = useCallback(() => { + matchRef.current = null; + setActiveItem(null); + setDismissedMatch(null); + setMatch(null); + }, []); + + const close = useCallback(() => { + const currentMatch = matchRef.current; + if (currentMatch) { + setDismissedMatch({ + start: currentMatch.range.start, + trigger: currentMatch.trigger, + }); + } + setActiveItem(null); + }, []); + + const isTextareaTarget = useCallback( + (target: EventTarget | null) => target === textareaRef.current, + [] + ); + + const setTextarea = useCallback( + (nextTextarea: HTMLTextAreaElement | null) => { + if (!nextTextarea) { + textareaRef.current = null; + formRef.current?.removeEventListener("reset", reset); + formRef.current = null; + reset(); + setFocused(false); + return; + } + + const nextForm = nextTextarea?.form ?? null; + if (formRef.current !== nextForm) { + formRef.current?.removeEventListener("reset", reset); + nextForm?.addEventListener("reset", reset); + formRef.current = nextForm; + } + textareaRef.current = nextTextarea; + }, + [reset] + ); + + useEffect( + () => () => { + formRef.current?.removeEventListener("reset", reset); + }, + [reset] + ); + + const updateMatch = useCallback( + ( + value: string, + selectionStart: number | null, + selectionEnd: number | null + ) => { + const nextMatch = findSuggestionMatch( + value, + selectionStart, + selectionEnd, + triggers + ); + const currentMatch = matchRef.current; + + if (isSameSuggestionMatch(currentMatch, nextMatch)) { + return; + } + + const movedToNewContext = + currentMatch?.trigger !== nextMatch?.trigger || + currentMatch?.range.start !== nextMatch?.range.start; + if (movedToNewContext) { + setActiveItem(null); + } + + if ( + !nextMatch || + dismissedMatch?.trigger !== nextMatch.trigger || + dismissedMatch.start !== nextMatch.range.start + ) { + setDismissedMatch(null); + } + + matchRef.current = nextMatch; + setMatch(nextMatch); + }, + [dismissedMatch, triggers] + ); + + const replace = useCallback( + (replacement: string) => { + const currentMatch = matchRef.current; + const target = textareaRef.current; + if (!(currentMatch && target)) { + return; + } + + const nextValue = `${target.value.slice( + 0, + currentMatch.range.start + )}${replacement}${target.value.slice(currentMatch.range.end)}`; + const nextCaret = currentMatch.range.start + replacement.length; + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value" + )?.set; + + reset(); + if (valueSetter) { + valueSetter.call(target, nextValue); + } else { + target.value = nextValue; + } + + target.focus({ preventScroll: true }); + target.setSelectionRange(nextCaret, nextCaret); + target.dispatchEvent(new Event("input", { bubbles: true })); + queueMicrotask(() => target.setSelectionRange(nextCaret, nextCaret)); + }, + [reset] + ); + + const registerItem = useCallback( + (item: RegisteredSuggestionItem) => { + itemsRef.current.set(item.id, item); + setActiveItem((currentActiveItem) => { + const currentItem = currentActiveItem + ? itemsRef.current.get(currentActiveItem.id) + : undefined; + const nextItem = + currentItem && !currentItem.disabled + ? currentItem + : getEnabledItems().at(0); + + if (!nextItem) { + return null; + } + + if ( + currentActiveItem?.id === nextItem.id && + currentActiveItem.value === nextItem.value + ) { + return currentActiveItem; + } + + return { id: nextItem.id, value: nextItem.value }; + }); + + return () => { + itemsRef.current.delete(item.id); + setActiveItem((currentActiveItem) => { + if (currentActiveItem?.id !== item.id) { + return currentActiveItem; + } + + const nextItem = getEnabledItems().at(0); + return nextItem ? { id: nextItem.id, value: nextItem.value } : null; + }); + }; + }, + [getEnabledItems] + ); + + const updateItem = useCallback( + (id: string, update: SuggestionItemUpdate) => { + const item = itemsRef.current.get(id); + if (!item) { + return; + } + + item.disabled = update.disabled; + item.value = update.value; + setActiveItem((currentActiveItem) => { + if (!currentActiveItem) { + const nextItem = getEnabledItems().at(0); + return nextItem ? { id: nextItem.id, value: nextItem.value } : null; + } + + if (currentActiveItem.id !== id) { + return currentActiveItem; + } + + if (update.disabled) { + const nextItem = getEnabledItems().at(0); + return nextItem ? { id: nextItem.id, value: nextItem.value } : null; + } + + return currentActiveItem.value === update.value + ? currentActiveItem + : { id, value: update.value }; + }); + }, + [getEnabledItems] + ); + + const setActiveId = useCallback( + (id: string) => { + const item = itemsRef.current.get(id); + if (item && !item.disabled) { + activateItem(item); + } + }, + [activateItem] + ); + + const moveActiveItem = useCallback( + (direction: 1 | -1): boolean => { + const items = getEnabledItems(); + if (items.length === 0) { + return false; + } + + const currentIndex = items.findIndex((item) => item.id === activeId); + let nextIndex = (currentIndex + direction + items.length) % items.length; + if (currentIndex === -1) { + nextIndex = direction === 1 ? 0 : items.length - 1; + } + const nextItem = items[nextIndex]; + if (!nextItem) { + return false; + } + + activateItem(nextItem); + nextItem.element.current?.scrollIntoView?.({ block: "nearest" }); + return true; + }, + [activateItem, activeId, getEnabledItems] + ); + + const handleKeyDown: KeyboardEventHandler = useCallback( + (event) => { + if (!open) { + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if (moveActiveItem(event.key === "ArrowDown" ? 1 : -1)) { + event.preventDefault(); + } + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + const activeItem = activeId + ? itemsRef.current.get(activeId) + : undefined; + if (activeItem && !activeItem.disabled) { + event.preventDefault(); + activeItem.select(); + } + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + close(); + return; + } + + if (event.key === "Tab") { + close(); + } + }, + [activeId, close, moveActiveItem, open] + ); + + const handlePopoverOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + close(); + } + }, + [close] + ); + + const context = useMemo( + () => ({ + activeId, + activeValue, + close, + handleKeyDown, + isTextareaTarget, + listboxId, + match, + open, + registerItem, + replace, + reset, + setActiveId, + setFocused, + setTextarea, + updateItem, + updateMatch, + }), + [ + activeId, + activeValue, + close, + handleKeyDown, + isTextareaTarget, + listboxId, + match, + open, + registerItem, + replace, + reset, + setActiveId, + setTextarea, + updateItem, + updateMatch, + ] + ); + + useEffect(() => onMatchChange?.(match), [match, onMatchChange]); + useEffect(() => onOpenChange?.(open), [onOpenChange, open]); + + return ( + + + {children} + + + ); +}; + +export type PromptInputSuggestionContentProps = ComponentProps< + typeof PopoverContent +>; + +export const PromptInputSuggestionContent = ({ + "aria-label": ariaLabel = "Suggestions", + align = "start", + className, + onCloseAutoFocus, + onInteractOutside, + onOpenAutoFocus, + side = "top", + sideOffset = 8, + ...props +}: PromptInputSuggestionContentProps) => { + const context = usePromptInputSuggestionsContext(); + const { isTextareaTarget } = context; + + const handleCloseAutoFocus = useCallback< + NonNullable + >( + (event) => { + onCloseAutoFocus?.(event); + if (!event.defaultPrevented) { + event.preventDefault(); + } + }, + [onCloseAutoFocus] + ); + + const handleInteractOutside = useCallback< + NonNullable + >( + (event) => { + onInteractOutside?.(event); + if (isTextareaTarget(event.detail.originalEvent.target)) { + event.preventDefault(); + } + }, + [isTextareaTarget, onInteractOutside] + ); + + const handleOpenAutoFocus = useCallback< + NonNullable + >( + (event) => { + onOpenAutoFocus?.(event); + if (!event.defaultPrevented) { + event.preventDefault(); + } + }, + [onOpenAutoFocus] + ); + + return ( + + ); +}; + +export type PromptInputSuggestionItemProps = Omit< + ComponentProps<"button">, + "onSelect" | "value" +> & { + onSelect?: (details: PromptInputSuggestionSelectDetails) => void; + replaceWith?: + | string + | false + | ((match: PromptInputSuggestionMatch) => string); + value: string; +}; + +export const PromptInputSuggestionItem = ({ + children, + className, + disabled = false, + id: idProp, + onClick, + onPointerDown, + onPointerMove, + onSelect, + ref, + replaceWith, + value, + ...props +}: PromptInputSuggestionItemProps) => { + const context = usePromptInputSuggestionsContext(); + const { close, match, registerItem, replace, setActiveId, updateItem } = + context; + + const generatedId = useId(); + const id = idProp ?? generatedId; + const disabledRef = useRef(disabled); + const elementRef = useRef(null); + const valueRef = useRef(value); + const isActive = context.activeId === id; + + const select = useCallback(() => { + if (!match || disabled) { + return; + } + + let didReplace = false; + const replaceSelection = (replacement: string) => { + didReplace = true; + replace(replacement); + }; + + onSelect?.({ + close, + match, + replace: replaceSelection, + value, + }); + + if (replaceWith !== false && !didReplace) { + const replacement = + typeof replaceWith === "function" + ? replaceWith(match) + : (replaceWith ?? `${match.trigger}${value} `); + replace(replacement); + } + close(); + }, [close, disabled, match, onSelect, replace, replaceWith, value]); + + const selectRef = useRef(select); + + useLayoutEffect(() => { + selectRef.current = select; + }, [select]); + + useLayoutEffect( + () => + registerItem({ + disabled: disabledRef.current, + element: elementRef, + id, + select: () => selectRef.current(), + value: valueRef.current, + }), + [id, registerItem] + ); + + useLayoutEffect(() => { + disabledRef.current = disabled; + valueRef.current = value; + updateItem(id, { disabled, value }); + }, [disabled, id, updateItem, value]); + + const handleClick: MouseEventHandler = useCallback( + (event) => { + onClick?.(event); + if (!event.defaultPrevented) { + select(); + } + }, + [onClick, select] + ); + + const handlePointerDown: PointerEventHandler = useCallback( + (event) => { + onPointerDown?.(event); + if (!event.defaultPrevented && !disabled) { + event.preventDefault(); + } + }, + [disabled, onPointerDown] + ); + + const handlePointerMove: PointerEventHandler = useCallback( + (event) => { + onPointerMove?.(event); + if (!event.defaultPrevented && !disabled) { + setActiveId(id); + } + }, + [disabled, id, onPointerMove, setActiveId] + ); + + const handleRef = useCallback( + (element: HTMLButtonElement | null) => { + elementRef.current = element; + setRefValue(ref, element); + }, + [ref] + ); + + return ( + + ); +}; + +export type PromptInputSuggestionEmptyProps = HTMLAttributes; + +export const PromptInputSuggestionEmpty = ({ + className, + role = "status", + ...props +}: PromptInputSuggestionEmptyProps) => ( +
+); + export type PromptInputActionAddAttachmentsProps = ComponentProps< typeof DropdownMenuItem > & { @@ -954,37 +1795,89 @@ export type PromptInputTextareaProps = ComponentProps< >; export const PromptInputTextarea = ({ + "aria-activedescendant": ariaActiveDescendant, + "aria-autocomplete": ariaAutocomplete, + "aria-controls": ariaControls, + "aria-expanded": ariaExpanded, + "aria-haspopup": ariaHasPopup, + className, + name = "message", + onBlur, onChange, + onCompositionEnd, + onCompositionStart, + onFocus, onKeyDown, - className, + onPaste, + onSelect, placeholder = "What would you like to know?", + ref, + role, + value, ...props }: PromptInputTextareaProps) => { const controller = useOptionalPromptInputController(); const attachments = usePromptInputAttachments(); - const [isComposing, setIsComposing] = useState(false); + const suggestions = usePromptInputSuggestionsInternal(); + const handleSuggestionKeyDown = suggestions?.handleKeyDown; + const resetSuggestions = suggestions?.reset; + const setSuggestionFocused = suggestions?.setFocused; + const setSuggestionTextarea = suggestions?.setTextarea; + const updateSuggestionMatch = suggestions?.updateMatch; + const isComposingRef = useRef(false); + const textareaRef = useRef(null); + + const handleRef = useCallback( + (textarea: HTMLTextAreaElement | null) => { + textareaRef.current = textarea; + if (textarea) { + setSuggestionTextarea?.(textarea); + } + setRefValue(ref, textarea); + }, + [ref, setSuggestionTextarea] + ); + + const updateSuggestions = useCallback( + (textarea: HTMLTextAreaElement) => { + updateSuggestionMatch?.( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd + ); + }, + [updateSuggestionMatch] + ); const handleKeyDown: KeyboardEventHandler = useCallback( - (e) => { + (event) => { + setSuggestionTextarea?.(event.currentTarget); + // Call the external onKeyDown handler first - onKeyDown?.(e); + onKeyDown?.(event); // If the external handler prevented default, don't run internal logic - if (e.defaultPrevented) { + if (event.defaultPrevented) { return; } - if (e.key === "Enter") { - if (isComposing || e.nativeEvent.isComposing) { - return; - } - if (e.shiftKey) { + if (isComposingRef.current || event.nativeEvent.isComposing) { + return; + } + + handleSuggestionKeyDown?.(event); + if (event.defaultPrevented) { + return; + } + + if (event.key === "Enter") { + if (event.shiftKey) { return; } - e.preventDefault(); + event.preventDefault(); // Check if the submit button is disabled before submitting - const { form } = e.currentTarget; + const { form } = event.currentTarget; const submitButton = form?.querySelector( 'button[type="submit"]' ) as HTMLButtonElement | null; @@ -997,22 +1890,27 @@ export const PromptInputTextarea = ({ // Remove last attachment when Backspace is pressed and textarea is empty if ( - e.key === "Backspace" && - e.currentTarget.value === "" && + event.key === "Backspace" && + event.currentTarget.value === "" && attachments.files.length > 0 ) { - e.preventDefault(); + event.preventDefault(); const lastAttachment = attachments.files.at(-1); if (lastAttachment) { attachments.remove(lastAttachment.id); } } }, - [onKeyDown, isComposing, attachments] + [attachments, handleSuggestionKeyDown, onKeyDown, setSuggestionTextarea] ); const handlePaste: ClipboardEventHandler = useCallback( (event) => { + onPaste?.(event); + if (event.defaultPrevented) { + return; + } + const items = event.clipboardData?.items; if (!items) { @@ -1035,37 +1933,125 @@ export const PromptInputTextarea = ({ attachments.add(files); } }, - [attachments] + [attachments, onPaste] + ); + + const handleChange: ChangeEventHandler = useCallback( + (event) => { + controller?.textInput.setInput(event.currentTarget.value); + onChange?.(event); + + const nativeEventIsComposing = + "isComposing" in event.nativeEvent && + event.nativeEvent.isComposing === true; + if (!(isComposingRef.current || nativeEventIsComposing)) { + updateSuggestions(event.currentTarget); + } + }, + [controller, onChange, updateSuggestions] + ); + + const handleCompositionStart: CompositionEventHandler = + useCallback( + (event) => { + onCompositionStart?.(event); + isComposingRef.current = true; + resetSuggestions?.(); + }, + [onCompositionStart, resetSuggestions] + ); + + const handleCompositionEnd: CompositionEventHandler = + useCallback( + (event) => { + onCompositionEnd?.(event); + isComposingRef.current = false; + updateSuggestions(event.currentTarget); + }, + [onCompositionEnd, updateSuggestions] + ); + + const handleFocus: FocusEventHandler = useCallback( + (event) => { + onFocus?.(event); + setSuggestionTextarea?.(event.currentTarget); + setSuggestionFocused?.(true); + updateSuggestions(event.currentTarget); + }, + [onFocus, setSuggestionFocused, setSuggestionTextarea, updateSuggestions] ); - const handleCompositionEnd = useCallback(() => setIsComposing(false), []); - const handleCompositionStart = useCallback(() => setIsComposing(true), []); + const handleBlur: FocusEventHandler = useCallback( + (event) => { + onBlur?.(event); + setSuggestionFocused?.(false); + }, + [onBlur, setSuggestionFocused] + ); - const controlledProps = controller - ? { - onChange: (e: ChangeEvent) => { - controller.textInput.setInput(e.currentTarget.value); - onChange?.(e); - }, - value: controller.textInput.value, + const handleSelect: ReactEventHandler = useCallback( + (event) => { + onSelect?.(event); + if (!isComposingRef.current) { + updateSuggestions(event.currentTarget); } - : { - onChange, - }; + }, + [onSelect, updateSuggestions] + ); - return ( + useEffect( + () => () => { + setSuggestionTextarea?.(null); + }, + [setSuggestionTextarea] + ); + + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + updateSuggestions(textarea); + } + }, [controller?.textInput.value, updateSuggestions, value]); + + const textarea = ( ); + + return suggestions ? ( + + {textarea} + + ) : ( + textarea + ); }; export type PromptInputHeaderProps = Omit< diff --git a/packages/examples/src/prompt-input.tsx b/packages/examples/src/prompt-input.tsx index d9411794..34dd37f8 100644 --- a/packages/examples/src/prompt-input.tsx +++ b/packages/examples/src/prompt-input.tsx @@ -19,7 +19,10 @@ import { ModelSelectorName, ModelSelectorTrigger, } from "@repo/elements/model-selector"; -import type { PromptInputMessage } from "@repo/elements/prompt-input"; +import type { + PromptInputMessage, + PromptInputSuggestionTrigger, +} from "@repo/elements/prompt-input"; import { PromptInput, PromptInputActionAddAttachments, @@ -31,10 +34,15 @@ import { PromptInputButton, PromptInputFooter, PromptInputProvider, + PromptInputSuggestionContent, + PromptInputSuggestionEmpty, + PromptInputSuggestionItem, + PromptInputSuggestions, PromptInputSubmit, PromptInputTextarea, PromptInputTools, usePromptInputAttachments, + usePromptInputSuggestions, } from "@repo/elements/prompt-input"; import { CheckIcon, GlobeIcon } from "lucide-react"; import { memo, useCallback, useState } from "react"; @@ -80,6 +88,53 @@ const models = [ const SUBMITTING_TIMEOUT = 200; const STREAMING_TIMEOUT = 2000; +interface PromptSuggestion { + description: string; + label: string; + value: string; +} + +const fileSuggestions: PromptSuggestion[] = [ + { + description: "Prompt composer and suggestion primitives", + label: "prompt-input.tsx", + value: "packages/elements/src/prompt-input.tsx", + }, + { + description: "Conversation layout and scroll behavior", + label: "conversation.tsx", + value: "packages/elements/src/conversation.tsx", + }, + { + description: "Chat message rendering components", + label: "message.tsx", + value: "packages/elements/src/message.tsx", + }, +]; + +const commandSuggestions: PromptSuggestion[] = [ + { + description: "Search project files and documentation", + label: "Search", + value: "search", + }, + { + description: "Summarize the current conversation", + label: "Summarize", + value: "summarize", + }, + { + description: "Explain the selected code or concept", + label: "Explain", + value: "explain", + }, +]; + +const suggestionTriggers = [ + { trigger: "@" }, + { startOfLine: true, trigger: "/" }, +] satisfies readonly PromptInputSuggestionTrigger[]; + interface AttachmentItemProps { attachment: { id: string; @@ -159,6 +214,60 @@ const PromptInputAttachmentsDisplay = () => { ); }; +const PromptInputSuggestionsDisplay = () => { + const { match } = usePromptInputSuggestions(); + + if (!match) { + return null; + } + + const suggestions = + match.trigger === "@" ? fileSuggestions : commandSuggestions; + const normalizedQuery = match.query.toLowerCase(); + const filteredSuggestions = suggestions.filter((suggestion) => + `${suggestion.label} ${suggestion.value} ${suggestion.description}` + .toLowerCase() + .includes(normalizedQuery) + ); + const groupLabel = match.trigger === "@" ? "Files" : "Commands"; + + return ( + +
+ {groupLabel} + Use arrow keys to navigate +
+ {filteredSuggestions.length > 0 ? ( + filteredSuggestions.map((suggestion) => ( + + + + + {suggestion.label} + + + {suggestion.description} + + + + )) + ) : ( + + No {groupLabel.toLowerCase()} found. + + )} +
+ ); +}; + const Example = () => { const [model, setModel] = useState(models[0].id); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); @@ -198,67 +307,70 @@ const Example = () => { return (
- - - - - - - - - - - - - - - - - Search - - - - - {selectedModelData?.chefSlug && ( - - )} - {selectedModelData?.name && ( - - {selectedModelData.name} - - )} - - - - - - No models found. - {["OpenAI", "Anthropic", "Google"].map((chef) => ( - - {models - .filter((m) => m.chef === chef) - .map((m) => ( - - ))} - - ))} - - - - - - - + + + + + + + + + + + + + + + + + + Search + + + + + {selectedModelData?.chefSlug && ( + + )} + {selectedModelData?.name && ( + + {selectedModelData.name} + + )} + + + + + + No models found. + {["OpenAI", "Anthropic", "Google"].map((chef) => ( + + {models + .filter((m) => m.chef === chef) + .map((m) => ( + + ))} + + ))} + + + + + + + + +
); diff --git a/skills/ai-elements/references/prompt-input.md b/skills/ai-elements/references/prompt-input.md index 681eeff6..b7324b6b 100644 --- a/skills/ai-elements/references/prompt-input.md +++ b/skills/ai-elements/references/prompt-input.md @@ -237,11 +237,68 @@ export async function POST(req: Request) { } ``` +## Mentions and slash commands + +Wrap the prompt in `PromptInputSuggestions` to open a keyboard-accessible suggestion list when the user types a configured trigger. The hook exposes the active trigger and query so you can filter local or remote results. + +Suggestions replace text in the existing textarea, so the prompt's plain-text submission contract remains unchanged. + +```tsx +const files = [ + "packages/elements/src/prompt-input.tsx", + "packages/elements/src/message.tsx", +]; +const commands = ["search", "summarize", "explain"]; +const triggers = [{ trigger: "@" }, { trigger: "/", startOfLine: true }]; + +const SuggestionMenu = () => { + const { match } = usePromptInputSuggestions(); + + if (!match) { + return null; + } + + const items = match.trigger === "@" ? files : commands; + const filteredItems = items.filter((item) => + item.toLowerCase().includes(match.query.toLowerCase()) + ); + + return ( + + {filteredItems.length > 0 ? ( + filteredItems.map((item) => ( + + {match.trigger} + {item} + + )) + ) : ( + + No results found. + + )} + + ); +}; + + + + + + + + +; +``` + +By default, selecting an item replaces the active query with its trigger and value followed by a space. Use `replaceWith` or `onSelect` on `PromptInputSuggestionItem` when you need custom insertion or selection behavior. + ## Features - Auto-resizing textarea that adjusts height based on content - File attachment support with drag-and-drop - Built-in screenshot capture action +- Contextual `@` mentions and `/` slash-command suggestions - Image preview for image attachments - Configurable file constraints (max files, max size, accepted types) - Automatic submit button icons based on status @@ -292,6 +349,57 @@ See `scripts/prompt-input-tooltip.tsx` for this example. |------|------|---------|-------------| | `...props` | `React.ComponentProps` | - | Any other props are spread to the underlying Textarea component. | +### `` + +Provides suggestion state and connects a descendant `PromptInputTextarea` to the suggestion content. + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `triggers` | `readonly PromptInputSuggestionTrigger[]` | Required | Trigger configurations that activate suggestions. | +| `onMatchChange` | `PromptInputSuggestionMatch change callback` | - | Called when the active trigger, query, or range changes. | +| `onOpenChange` | `(open: boolean) => void` | - | Called when the suggestion popover opens or closes. | +| `children` | `React.ReactNode` | - | The prompt input and suggestion content. | + +#### `PromptInputSuggestionTrigger` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `trigger` | `string` | Required | Text that activates suggestions, for example an at sign or slash. | +| `allowSpaces` | `boolean` | `false` | Whether the active query may contain spaces. | +| `allowedPrefixes` | `readonly string array or null` | `space, newline, or tab` | Characters allowed immediately before the trigger. Use null to allow any prefix. | +| `minQueryLength` | `number` | `0` | Minimum query length required before matching. | +| `maxQueryLength` | `number` | - | Maximum query length that remains active. | +| `startOfLine` | `boolean` | `false` | Whether the trigger must appear at the start of a line. | + +### `` + +Portal-backed listbox positioned relative to the prompt textarea. + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `aria-label` | `string` | `Suggestions` | Accessible label for the suggestion listbox. | +| `align` | `start, center, or end` | `start` | Alignment relative to the textarea. | +| `side` | `top, right, bottom, or left` | `top` | Preferred side of the textarea. | +| `sideOffset` | `number` | `8` | Distance from the textarea in pixels. | +| `...props` | `React.ComponentProps` | - | Any other props are spread to PopoverContent. | + +### `` + +Keyboard- and pointer-selectable suggestion option. The default replacement is `{trigger}{value} `. + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `value` | `string` | Required | Value used for navigation state and default insertion. | +| `replaceWith` | `string, false, or replacement function` | - | Custom replacement text or function. Set false to handle insertion yourself. | +| `onSelect` | `(details: PromptInputSuggestionSelectDetails) => void` | - | Called with the selected value, match, and replacement helpers. | +| `...props` | `React button props except onSelect and value` | - | Any other props are spread to the underlying button. | + +### `` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `...props` | `React.HTMLAttributes` | - | Any other props are spread to the empty-state div. | + ### `` | Prop | Type | Default | Description | @@ -525,6 +633,23 @@ Optional global provider that lifts PromptInput state outside of PromptInput. Wh ## Hooks +### `usePromptInputSuggestions` + +Access the active suggestion state within `PromptInputSuggestions`. + +```tsx +const { activeValue, close, match, open, replace } = + usePromptInputSuggestions(); + +match?.trigger; // Active trigger +match?.query; // Text after the trigger +match?.range; // Trigger-to-caret replacement range +activeValue; // Value of the keyboard-highlighted item +open; // Whether the suggestion list is open +replace("@replacement "); // Replace the active range and restore focus +close(); // Dismiss the current match +``` + ### `usePromptInputAttachments` Access and manage file attachments within a PromptInput context. diff --git a/skills/ai-elements/scripts/prompt-input.tsx b/skills/ai-elements/scripts/prompt-input.tsx index bfe082d6..a3cca4cb 100644 --- a/skills/ai-elements/scripts/prompt-input.tsx +++ b/skills/ai-elements/scripts/prompt-input.tsx @@ -19,7 +19,10 @@ import { ModelSelectorName, ModelSelectorTrigger, } from "@/components/ai-elements/model-selector"; -import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import type { + PromptInputMessage, + PromptInputSuggestionTrigger, +} from "@/components/ai-elements/prompt-input"; import { PromptInput, PromptInputActionAddAttachments, @@ -31,10 +34,15 @@ import { PromptInputButton, PromptInputFooter, PromptInputProvider, + PromptInputSuggestionContent, + PromptInputSuggestionEmpty, + PromptInputSuggestionItem, + PromptInputSuggestions, PromptInputSubmit, PromptInputTextarea, PromptInputTools, usePromptInputAttachments, + usePromptInputSuggestions, } from "@/components/ai-elements/prompt-input"; import { CheckIcon, GlobeIcon } from "lucide-react"; import { memo, useCallback, useState } from "react"; @@ -80,6 +88,53 @@ const models = [ const SUBMITTING_TIMEOUT = 200; const STREAMING_TIMEOUT = 2000; +interface PromptSuggestion { + description: string; + label: string; + value: string; +} + +const fileSuggestions: PromptSuggestion[] = [ + { + description: "Prompt composer and suggestion primitives", + label: "prompt-input.tsx", + value: "packages/elements/src/prompt-input.tsx", + }, + { + description: "Conversation layout and scroll behavior", + label: "conversation.tsx", + value: "packages/elements/src/conversation.tsx", + }, + { + description: "Chat message rendering components", + label: "message.tsx", + value: "packages/elements/src/message.tsx", + }, +]; + +const commandSuggestions: PromptSuggestion[] = [ + { + description: "Search project files and documentation", + label: "Search", + value: "search", + }, + { + description: "Summarize the current conversation", + label: "Summarize", + value: "summarize", + }, + { + description: "Explain the selected code or concept", + label: "Explain", + value: "explain", + }, +]; + +const suggestionTriggers = [ + { trigger: "@" }, + { startOfLine: true, trigger: "/" }, +] satisfies readonly PromptInputSuggestionTrigger[]; + interface AttachmentItemProps { attachment: { id: string; @@ -159,6 +214,60 @@ const PromptInputAttachmentsDisplay = () => { ); }; +const PromptInputSuggestionsDisplay = () => { + const { match } = usePromptInputSuggestions(); + + if (!match) { + return null; + } + + const suggestions = + match.trigger === "@" ? fileSuggestions : commandSuggestions; + const normalizedQuery = match.query.toLowerCase(); + const filteredSuggestions = suggestions.filter((suggestion) => + `${suggestion.label} ${suggestion.value} ${suggestion.description}` + .toLowerCase() + .includes(normalizedQuery) + ); + const groupLabel = match.trigger === "@" ? "Files" : "Commands"; + + return ( + +
+ {groupLabel} + Use arrow keys to navigate +
+ {filteredSuggestions.length > 0 ? ( + filteredSuggestions.map((suggestion) => ( + + + + + {suggestion.label} + + + {suggestion.description} + + + + )) + ) : ( + + No {groupLabel.toLowerCase()} found. + + )} +
+ ); +}; + const Example = () => { const [model, setModel] = useState(models[0].id); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); @@ -198,67 +307,70 @@ const Example = () => { return (
- - - - - - - - - - - - - - - - - Search - - - - - {selectedModelData?.chefSlug && ( - - )} - {selectedModelData?.name && ( - - {selectedModelData.name} - - )} - - - - - - No models found. - {["OpenAI", "Anthropic", "Google"].map((chef) => ( - - {models - .filter((m) => m.chef === chef) - .map((m) => ( - - ))} - - ))} - - - - - - - + + + + + + + + + + + + + + + + + + Search + + + + + {selectedModelData?.chefSlug && ( + + )} + {selectedModelData?.name && ( + + {selectedModelData.name} + + )} + + + + + + No models found. + {["OpenAI", "Anthropic", "Google"].map((chef) => ( + + {models + .filter((m) => m.chef === chef) + .map((m) => ( + + ))} + + ))} + + + + + + + + +
);