/* * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import Attach from '@react-spectrum/s2/icons/Attach'; import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList'; import {Autocomplete} from 'react-aria-components/Autocomplete'; import { baseColor, color, css, iconStyle, style, StyleString } from '@react-spectrum/s2/style' with {type: 'macro'}; import {Button} from '@react-spectrum/s2/Button'; import {Cell} from './loader/data'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import { createContext, createRef, forwardRef, use, useContext, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import {DOMRef} from '@react-types/shared'; import {IconContext} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; import intlMessages from '../intl/*.json'; // @ts-ignore import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; import {Link} from '@react-spectrum/s2/Link'; import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; import Microphone from '@react-spectrum/s2/icons/Microphone'; import {PixelLoader} from './loader/react'; import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; import { Position, TokenFieldSegment, TokenFieldValue, TokenSegment } from 'react-stately/useTokenFieldState'; import {PromptFieldContainer} from './PromptFieldContainer'; import {PromptFocusContext} from './Chat'; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import {setTokenFieldSelection} from 'react-aria/useTokenField'; import Stop from '@react-spectrum/s2/icons/StopProcessing'; import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; import { Token, TokenField, tokenFieldPositionToDOMRange, TokenInput, TokenProps } from 'react-aria-components/TokenField'; import {Tooltip, TooltipTrigger} from '@react-spectrum/s2/Tooltip'; import {useControlledState} from 'react-stately/useControlledState'; import {useDOMRef} from './useDOMRef'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusWithin} from 'react-aria/useFocusWithin'; import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; export interface PromptFieldAttachment { id: string; file: File; image: string; } export interface PromptFieldProps { children: React.ReactNode; acceptedAttachmentTypes?: string[]; value?: TokenFieldValue; defaultValue?: TokenFieldValue; onChange?: (value: TokenFieldValue) => void; attachments?: PromptFieldAttachment[]; defaultAttachments?: PromptFieldAttachment[]; onAttachmentsChange?: (attachments: PromptFieldAttachment[]) => void; onSubmit?: (prompt: TokenFieldValue, attachments: PromptFieldAttachment[]) => void; isGenerating?: boolean; onStop?: () => void; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; styles?: StyleString; variant?: 'balanced' | 'prominent' | 'subtle'; brandColor?: string; } interface PromptFieldState { attachments: PromptFieldAttachment[]; setAttachments: React.Dispatch>; acceptedAttachmentTypes?: string[]; prompt: TokenFieldValue; setPrompt: React.Dispatch>; inputRef: React.RefObject; onSubmit?: () => void; onStop?: () => void; isGenerating: boolean; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; isListening: boolean; setListening: React.Dispatch>; } // TODO: make this customizable const tokenRegex = /(?<=\s|^)(https?:\/\/)?(www\.)?([^/\s]+\.[a-z]{2,}(\/\S+)?)(?=\s)/g; function tokenizeURLs(text: string): TokenFieldSegment[] { if (text.length === 0) { return [{type: 'text', text}]; } tokenRegex.lastIndex = 0; let match: RegExpExecArray | null = null; let start = 0; let segments: TokenFieldSegment[] = []; while ((match = tokenRegex.exec(text))) { if (match.index > start) { segments.push({type: 'text', text: text.slice(start, match.index)}); } segments.push({type: 'token', text: match[3], value: {type: 'url', url: match[0]}}); start = match.index + match[0].length; } if (start < text.length) { segments.push({type: 'text', text: text.slice(start)}); } return segments; } export class PromptFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { return tokenizeURLs(text); } } const PromptFieldContext = createContext({ attachments: [], setAttachments: () => {}, prompt: new PromptFieldValue([]), setPrompt: () => {}, inputRef: createRef(), isGenerating: false, isListening: false, setListening: () => {} }); // to communicate the anchor position to the menu items in the completion popover // need this so we can replace the inline filter text rather than inserting it at the current caret // aka the difference between a slash command and using the + menu which won't have filter text const PromptCompletionAnchorContext = createContext(null); function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { return acceptedMimeTypes.some(type => { if (type === '*/*') { return true; } if (type.endsWith('/*')) { return mimeType.startsWith(type.slice(0, -2)); } return mimeType === type; }); } export const PromptField = forwardRef(function PromptField( props: PromptFieldProps, ref: DOMRef ) { let { children, acceptedAttachmentTypes, isGenerating, onStop, styles, onAddAttachments, onRemoveAttachments, variant = 'balanced', brandColor } = props; let domRef = useDOMRef(ref); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [prompt, setPrompt] = useControlledState( props.value, props.defaultValue ?? new PromptFieldValue([]), props.onChange ); let [attachments, setAttachments] = useControlledState( props.attachments, props.defaultAttachments ?? [], props.onAttachmentsChange ); // Not using RAC DropZone because it adds its own focusable button, // and we want to avoid an extra tab. We support pasting files directly into the input. let inputRef = useRef(null); let {dropProps, isDropTarget} = useDrop({ ref: inputRef, hasDropButton: true, isDisabled: !acceptedAttachmentTypes, getDropOperation(types) { return acceptedAttachmentTypes && types.has(acceptedAttachmentTypes) ? 'copy' : 'cancel'; }, async onDrop(e) { let files = await Promise.all( e.items .filter(isFileDropItem) .filter(item => matchMimeType(item.type, acceptedAttachmentTypes!)) .map(async item => ({ id: crypto.randomUUID(), file: await item.getFile(), image: item.type.startsWith('image/') ? URL.createObjectURL(await item.getFile()) : '' })) ); onAddAttachments?.(files); setAttachments(attachments => [...attachments, ...files]); } }); let [isListening, setListening] = useState(false); let {onFocusChange} = useContext(PromptFocusContext); let {focusWithinProps} = useFocusWithin({onFocusWithinChange: onFocusChange}); let isPromptControlled = props.value !== undefined; let isAttachmentsControlled = props.attachments !== undefined; let onSubmit = () => { if (prompt.segments.length === 0) { return; } props.onSubmit?.(prompt, attachments); if (!isPromptControlled) { setPrompt(new PromptFieldValue([])); } if (!isAttachmentsControlled) { setAttachments([]); } inputRef.current?.focus(); }; return (
{children}

{stringFormatter.format('promptfield.aiDisclaimer')}{' '} {stringFormatter.format('promptfield.aiUserGuidlines')}

); }); export interface PromptFieldAttachmentListProps extends AttachmentListProps { children?: (attachment: PromptFieldAttachment) => React.ReactNode; } export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) { let {children} = props; let {attachments, setAttachments, onRemoveAttachments, inputRef} = useContext(PromptFieldContext); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); if (attachments.length === 0) { return null; } return ( { let removedAttachments = attachments.filter(attachment => keys.has(attachment.id)); onRemoveAttachments?.(removedAttachments); setAttachments(attachments => attachments.filter(attachment => !keys.has(attachment.id))); if (removedAttachments.length === attachments.length) { inputRef.current?.focus(); } }} items={attachments}> {children || (attachment => ( {attachment.image && } ))} ); } export interface PromptTokenFieldProps { completionTrigger?: RegExp; renderCompletions?: ( filterValue: string ) => React.ReactNode[] | null | Promise; children?: (segment: TokenSegment) => React.ReactElement; pixelLoader?: Cell[] | Cell[][]; placeholder?: string; onKeyDown?: (e: React.KeyboardEvent) => void; } export function PromptTokenField(props: PromptTokenFieldProps) { let { completionTrigger, renderCompletions, children, pixelLoader, placeholder, onKeyDown: onKeyDownProp } = props; let {keyboardProps} = useKeyboard({onKeyDown: onKeyDownProp}); let { prompt, setPrompt, acceptedAttachmentTypes, setAttachments, onAddAttachments, inputRef, onSubmit, isGenerating, isListening } = useContext(PromptFieldContext); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [isFocused, setFocused] = useState(false); let [filterAnchor, filterValue] = useMemo(() => { if (completionTrigger) { let filterAnchor = prompt.findText( prompt.caretPosition, TokenFieldValue.Direction.Backward, completionTrigger ); if (filterAnchor != null) { let filterValue = prompt.slice(filterAnchor, prompt.caretPosition).toString(); return [filterAnchor, filterValue]; } } return [null, null]; }, [completionTrigger, prompt]); let items = useMemo(() => { return filterValue != null ? renderCompletions?.(filterValue) : null; }, [filterValue, renderCompletions]); return (
0})}> { if (e.isTrusted) { setFocused(true); } }} onBlur={e => { if (e.isTrusted) { setFocused(false); } }} onPaste={ acceptedAttachmentTypes ? e => { let clipboardData = e.clipboardData as DataTransfer; let attachments: PromptFieldAttachment[] = []; for (let item of clipboardData.items) { if (matchMimeType(item.type, acceptedAttachmentTypes)) { let file = item.getAsFile()!; attachments.push({ id: crypto.randomUUID(), file, image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' }); } } if (attachments.length > 0) { onAddAttachments?.(attachments); setAttachments(prev => [...prev, ...attachments]); } } : undefined }> css('&:empty::before { content: attr(data-placeholder); }') + style({ font: 'body', color: { default: baseColor('neutral'), ':empty': { default: 'gray-600', forcedColors: 'GrayText' } }, width: 'full', outlineStyle: 'none', cursor: 'text' })(renderProps) }> {children || (segment => {segment.text})}
); } export interface PromptTokenFieldPopoverProps extends Omit { filterAnchor?: Position | null; items?: React.ReactNode[] | null | Promise; isFocused?: boolean; } function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { let {filterAnchor, items, isFocused} = props; let {inputRef} = useContext(PromptFieldContext); let resolvedItems = items instanceof Promise ? use(items) : items; let isOpen = isFocused && filterAnchor != null && resolvedItems != null && resolvedItems.length > 0; // Cache items so that popover content doesn't flicker to empty while animating out let [menuItems, setMenuItems] = useState(resolvedItems); if (resolvedItems !== menuItems && resolvedItems != null && resolvedItems.length > 0) { setMenuItems(resolvedItems); } return ( { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> {menuItems} ); } export interface PromptTokenProps extends Omit { children: React.ReactNode; } export function PromptToken(props: PromptTokenProps) { return ( {icon} }}> {props.children} ); } export interface PromptFieldToolbarProps { children: React.ReactNode; } export function PromptFieldToolbar(props: PromptFieldToolbarProps) { let {children} = props; return (
{children}
); } export interface PromptFieldSubmitButtonProps {} // eslint-disable-next-line @typescript-eslint/no-unused-vars export function PromptFieldSubmitButton(props: PromptFieldSubmitButtonProps) { let {prompt, isGenerating, onSubmit, onStop} = useContext(PromptFieldContext); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); return ( ); } export interface PromptFieldVoiceButtonProps { lang?: string; isDisabled?: boolean; onError?: (code: VoiceInputErrorCode) => void; } export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { let {lang: langProp, isDisabled: isDisabledProp, onError} = props; let {locale} = useLocale(); let lang = langProp ?? locale; let {prompt, setPrompt, inputRef, setListening} = useContext(PromptFieldContext); let isDisabled = isDisabledProp; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let basePromptRef = useRef(prompt); let updateBasePrompt = useEffectEvent(() => { basePromptRef.current = prompt; }); let { isSupported, isListening: isVoiceListening, transcript, toggle, stop } = useVoiceInput({lang, onError, onListeningChange: setListening}); let restoreFocus = useEffectEvent(() => { if (!inputRef.current) { return; } // similar to useInsertPromptSegment, calling programatic focus on the input causes the caret positioning // to be inaccurate let finalPrompt = buildVoicePrompt(basePromptRef.current, transcript); inputRef.current.focus(); setTokenFieldSelection(inputRef.current, finalPrompt.caretPosition, finalPrompt.caretPosition); setPrompt(finalPrompt); }); let wasListeningRef = useRef(false); useEffect(() => { if (isVoiceListening) { updateBasePrompt(); wasListeningRef.current = true; } else if (wasListeningRef.current) { wasListeningRef.current = false; restoreFocus(); } }, [isVoiceListening]); let applyVoiceTranscript = useEffectEvent(() => { if (!transcript || !isVoiceListening) { return; } setPrompt(buildVoicePrompt(basePromptRef.current, transcript)); }); useEffect(() => { applyVoiceTranscript(); }, [transcript, isVoiceListening]); useEffect(() => { if (isDisabled && isVoiceListening) { stop(); } }, [isDisabled, isVoiceListening, stop]); if (!isSupported) { return null; } let label = isVoiceListening ? stringFormatter.format('voicebutton.stopListening') : stringFormatter.format('voicebutton.startListening'); return ( {label} ); } function buildVoicePrompt(base: TokenFieldValue, voiceText: string): PromptFieldValue { if (!voiceText) { return base as PromptFieldValue; } return base.replaceRange(base.caretPosition, base.caretPosition, voiceText) as PromptFieldValue; } export interface InsertMenuItemProps { children: React.ReactNode; } export function InsertMenuButton(props: InsertMenuItemProps) { let {children} = props; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); return ( {children} ); } export function AttachFileMenuItem() { let {acceptedAttachmentTypes, setAttachments, onAddAttachments} = useContext(PromptFieldContext); return ( { let input = document.createElement('input'); input.type = 'file'; if (acceptedAttachmentTypes) { input.accept = acceptedAttachmentTypes.join(','); } input.multiple = true; input.onchange = e => { let files = (e.currentTarget as HTMLInputElement).files; if (files && acceptedAttachmentTypes) { let attachments = Array.from(files) .filter(file => matchMimeType(file.type, acceptedAttachmentTypes)) .map(file => ({ id: crypto.randomUUID(), file, image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' })); if (attachments.length > 0) { onAddAttachments?.(attachments); setAttachments(prev => [...prev, ...attachments]); } } }; input.click(); }}> Attach a file ); } // either replace the filter text (aka token replace) or insert value at current caret position (aka plain text inject) function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[]) { let {setPrompt, inputRef} = useContext(PromptFieldContext); let anchor = useContext(PromptCompletionAnchorContext); let pendingCaret = useRef(null); return (item: any) => { setPrompt(value => { let newValue = value.replaceRangeWithSegments( anchor ?? value.caretPosition, value.caretPosition, buildSegments(item), false // Don't coalesce in undo/redo history. ); pendingCaret.current = newValue.caretPosition; return newValue; }); if (anchor == null) { // Wait for popover animation, then restore cursor to after the inserted content. setTimeout(() => { if (inputRef.current && pendingCaret.current) { let position = pendingCaret.current; pendingCaret.current = null; inputRef.current.focus(); // we need to update the position manually since TokenField's update caret logic only happens if the field is focused // but this insert can happen from the + menu aka the field isn't focused until this gets called which is too late setTokenFieldSelection(inputRef.current, position, position); // the above focus and setCursor call can cause the internally tracked caret position to be reset incorrectly // seemingly due to TokenField's isProgrammaticSelectionChange being flipped to false by setCursor and thus reset to 0 by the .focus // fix this by resetting to proper position below // happens when injecting multiple tokens one after another via + menu setPrompt(value => value.withCaretPosition(position)); } }, 400); } }; } export interface InsertTokenMenuItemProps extends Omit< MenuItemProps, | 'UNSAFE_className' | 'UNSAFE_style' | 'download' | 'href' | 'hrefLang' | 'ping' | 'referrerPolicy' | 'rel' | 'routerOptions' | 'target' > {} export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) { let insert = useInsertPromptSegment(item => [ {type: 'token', text: 'command' in item ? item.command : item.title, value: item}, {type: 'text', text: ' '} ]); return ( { insert(props.value); props.onAction?.(); }} /> ); } export interface InsertTextMenuItemProps extends Omit< MenuItemProps, | 'UNSAFE_className' | 'UNSAFE_style' | 'download' | 'href' | 'hrefLang' | 'ping' | 'referrerPolicy' | 'rel' | 'routerOptions' | 'target' > {} export function InsertTextMenuItem(props: InsertTextMenuItemProps) { let insert = useInsertPromptSegment(item => [ {type: 'text', text: `${'command' in item ? item.command : item.title} `} ]); return ( { insert(props.value); props.onAction?.(); }} /> ); } export interface CommandMenuItemProps extends Omit< MenuItemProps, | 'UNSAFE_className' | 'UNSAFE_style' | 'download' | 'href' | 'hrefLang' | 'ping' | 'referrerPolicy' | 'rel' | 'routerOptions' | 'target' > {} // specifically for menu items that only trigger a callback in the autocomplete menu // since they dont end up inserting a token or text, we need to clear the partial text that the user used // to filter the menu export function CommandMenuItem(props: CommandMenuItemProps) { let insert = useInsertPromptSegment(() => []); return ( { insert(undefined); props.onAction?.(); }} /> ); }