diff --git a/.github/labeler.yml b/.github/labeler.yml index 49df0a71d12..c3350301447 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -20,7 +20,7 @@ v3: - all: - changed-files: - all-globs-to-any-file: ['**/@react-spectrum/**', '!**/*.stories.*', '!**/*.test.*'] - - all-globs-to-all-files: '!**/@react-spectrum/s2/**' + - all-globs-to-all-files: ['!**/@react-spectrum/s2/**', '!**/@react-spectrum/ai/**'] Accordion: - changed-files: diff --git a/.storybook/.parcelrc b/.storybook/.parcelrc index f725210d16d..83fa6373975 100644 --- a/.storybook/.parcelrc +++ b/.storybook/.parcelrc @@ -3,6 +3,8 @@ "resolvers": ["@parcel/resolver-glob", "..."], "transformers": { "packages/**/intl/**/*.json": ["parcel-transformer-intl"], + "starters/**/*.css": ["@parcel/transformer-css"], + "packages/react-aria-components/stories/styles.global.css": ["@parcel/transformer-css"], "raw:*": ["@parcel/transformer-raw"] } } diff --git a/package.json b/package.json index 63938ee5375..792f717e23a 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "start:mcp": "yarn workspace @react-spectrum/s2-docs generate:md && yarn build:mcp && node packages/dev/mcp/s2/dist/index.js && node packages/dev/mcp/react-aria/dist/index.js", "test:mcp": "yarn build:s2-docs && yarn build:mcp && node packages/dev/mcp/scripts/smoke-list-pages.mjs", "test": "cross-env STRICT_MODE=1 VIRT_ON=1 yarn jest", - "test:browser": " yarn playwright install && vitest --config=vitest.browser.config.ts", + "test:browser": "yarn playwright install && vitest run --config=vitest.browser.config.ts", + "test:browser:watch": "yarn playwright install && vitest --config=vitest.browser.config.ts", "test:lint": "ls packages/**/*.test-lint.js | xargs -n 1 node", "test-loose": "cross-env VIRT_ON=1 yarn jest", "test-storybook": "test-storybook --url http://localhost:9003 --browsers chromium --no-cache", diff --git a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx index c59b9c93a41..016a8c620ec 100644 --- a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx +++ b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx @@ -110,8 +110,9 @@ function ActionBarInner(props: ActionBarInnerProps, ref: Ref { + onKeyDown(e) { + if (e.key === 'Escape') { + e.preventDefault(); onClearSelection(); } } diff --git a/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx b/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx index c7eb46dd757..f5c9a9755c4 100644 --- a/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx +++ b/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx @@ -286,7 +286,7 @@ export const ActionGroup = forwardRef(function ActionGroup( onAction?.(key)} isDisabled={isDisabled} isEmphasized={isEmphasized} staticColor={staticColor} diff --git a/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx b/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx index 0b9a213e77a..6725bd065b5 100644 --- a/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx +++ b/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx @@ -221,12 +221,12 @@ export class BaseLayout getFirstKey(): Node | undefined { let firstRow = this.collection.getItem(this.collection.getFirstKey()); - return getFirstItem(getChildNodes(firstRow, this.collection))?.key; + return firstRow ? getFirstItem(getChildNodes(firstRow, this.collection))?.key : null; } getLastKey(): Node | undefined { let lastRow = this.collection.getItem(this.collection.getLastKey()); - return getFirstItem(getChildNodes(lastRow, this.collection))?.key; + return lastRow ? getFirstItem(getChildNodes(lastRow, this.collection))?.key : null; } // TODO: pretty unwieldy because it needs to bounce back and forth between the parent key and the child key diff --git a/packages/@adobe/react-spectrum/test/menu/Menu.test.js b/packages/@adobe/react-spectrum/test/menu/Menu.test.js index 3cf74d2321f..9fd89ef7613 100644 --- a/packages/@adobe/react-spectrum/test/menu/Menu.test.js +++ b/packages/@adobe/react-spectrum/test/menu/Menu.test.js @@ -659,15 +659,15 @@ describe('Menu', function () { ]; await user.click(item1); - expect(onAction).toHaveBeenCalledWith('One'); + expect(onAction).toHaveBeenCalledWith('One', null); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item2); - expect(onAction).toHaveBeenCalledWith('Two'); + expect(onAction).toHaveBeenCalledWith('Two', null); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item3); - expect(onAction).toHaveBeenCalledWith('Three'); + expect(onAction).toHaveBeenCalledWith('Three', null); expect(onSelectionChange).toHaveBeenCalledTimes(0); }); @@ -701,15 +701,15 @@ describe('Menu', function () { ]; await user.click(item1); - expect(onAction).toHaveBeenCalledWith('One'); + expect(onAction).toHaveBeenCalledWith('One', {name: 'One'}); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item2); - expect(onAction).toHaveBeenCalledWith('Two'); + expect(onAction).toHaveBeenCalledWith('Two', {name: 'Two'}); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item3); - expect(onAction).toHaveBeenCalledWith('Three'); + expect(onAction).toHaveBeenCalledWith('Three', {name: 'Three'}); expect(onSelectionChange).toHaveBeenCalledTimes(0); }); diff --git a/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx b/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx index 03468f2c4b9..c9af0ac3780 100644 --- a/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx +++ b/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx @@ -656,7 +656,7 @@ describe('Submenu', function () { expect(onAction).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(submenuOnAction).toHaveBeenCalledTimes(1); - expect(submenuOnAction).toHaveBeenLastCalledWith('Lvl 2 Item 2'); + expect(submenuOnAction).toHaveBeenLastCalledWith('Lvl 2 Item 2', null); expect(submenuOnClose).toHaveBeenCalledTimes(1); expect(onOpenChange).toHaveBeenCalledTimes(4); expect(onOpenChange).toHaveBeenLastCalledWith(false); @@ -725,7 +725,7 @@ describe('Submenu', function () { menus = tree.queryAllByRole('menu'); expect(menus).toHaveLength(0); expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('Lvl 1 Item 1'); + expect(onAction).toHaveBeenLastCalledWith('Lvl 1 Item 1', null); }); it('supports selectionMode and onSelectionChange on submenus', async function () { diff --git a/packages/@adobe/spectrum-css-temp/components/buttongroup/index.css b/packages/@adobe/spectrum-css-temp/components/buttongroup/index.css index 29ddd0104d7..41d5b383d99 100644 --- a/packages/@adobe/spectrum-css-temp/components/buttongroup/index.css +++ b/packages/@adobe/spectrum-css-temp/components/buttongroup/index.css @@ -19,6 +19,7 @@ governing permissions and limitations under the License. align-items: flex-start; /* necessary so that offsetLeft on button children is correct */ position: relative; + max-width: 100%; .spectrum-ButtonGroup-Button { flex-shrink: 0; diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 2d864dce7df..e5b8b93e847 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMRef} from '@react-types/shared'; +import {AriaLabelingProps, DOMRef, forwardRefType} from '@react-types/shared'; import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; @@ -18,9 +18,12 @@ import {CardProps} from '@react-spectrum/s2/Card'; import Close from '@react-spectrum/s2/icons/Close'; import {forwardRef, useRef} from 'react'; import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ImageContext} from '@react-spectrum/s2/Image'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; -import {Tag, TagGroup, TagList} from 'react-aria-components/TagGroup'; +import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import {StyleProps, TagProps} from '@react-spectrum/s2'; +import {Tag, TagGroup, TagGroupProps, TagList, TagListProps} from 'react-aria-components/TagGroup'; import {useDOMRef} from './useDOMRef'; const controlSizeM = { @@ -103,14 +106,22 @@ const CloseButton = function CloseButton(props) { ); }; -export const AttachmentList = forwardRef(function AttachmentList( - props: any, +export interface AttachmentListProps + extends + Omit, + StyleProps, + Pick, 'items' | 'children' | 'dependencies'> {} + +export const AttachmentList = (forwardRef as forwardRefType)(function AttachmentList( + props: AttachmentListProps, ref: DOMRef ) { let domRef = useDOMRef(ref); return ( { + uploadProgress?: number; +} + export const Attachment = forwardRef(function Attachment( - props: CardProps & AriaLabelingProps, + props: AttachmentProps, ref: DOMRef ) { let { + id, textValue, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, @@ -139,6 +156,7 @@ export const Attachment = forwardRef(function Attachment( let domRef = useDOMRef(ref); return ( - {props.children} + + {props.uploadProgress != null && props.uploadProgress < 100 && ( +
+ +
+ )} + {/* Reduce opacity of the thumbnail if upload is in progress */} + + {ctx => ( + + {typeof props.children === 'function' + ? props.children({size: otherProps.size || 'M'}) + : props.children} + + )} + +
{/** Definitely not a close button, though looks like one. */}
- + linkStyles({size, ...renderProps})}> {children} @@ -244,7 +244,7 @@ export const NumberBadge = forwardRef(function NumberBadge( props: NumberBadgeProps, ref: DOMRef ) { - let {size = 'S', value, ...otherProps} = props; + let {size = 'M', value, ...otherProps} = props; let domRef = useDOMRef(ref); let {locale} = useLocale(); let formattedValue = ''; diff --git a/packages/@react-spectrum/ai/src/MessageSuggestion.tsx b/packages/@react-spectrum/ai/src/MessageSuggestion.tsx index 1a8947c42c3..c1daf4303b1 100644 --- a/packages/@react-spectrum/ai/src/MessageSuggestion.tsx +++ b/packages/@react-spectrum/ai/src/MessageSuggestion.tsx @@ -12,9 +12,14 @@ import {AriaLabelingProps, DOMProps, DOMRef} from '@react-types/shared'; import ArrowCurved from '@react-spectrum/s2/icons/ArrowCurved'; +import { + baseColor, + centerPadding, + focusRing, + style +} from '@react-spectrum/s2/style' with {type: 'macro'}; import {ButtonProps, Button as RACButton} from 'react-aria-components/Button'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; -import {centerPadding, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {IconContext} from '@react-spectrum/s2/Icon'; @@ -79,14 +84,11 @@ const suggestionStyles = style<{ XL: 'body-xl' } }, - backgroundColor: { - default: 'gray-100', - isHovered: 'gray-200', - isPressed: 'gray-300' - }, + backgroundColor: baseColor('gray-100'), color: 'neutral', borderStyle: 'none', disableTapHighlight: true, + transition: 'default', ...focusRing() }); diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx new file mode 100644 index 00000000000..94a44d14cbd --- /dev/null +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -0,0 +1,604 @@ +/* + * 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, css, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Button} from '@react-spectrum/s2/Button'; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; +import { + createContext, + createRef, + use, + useContext, + useDeferredValue, + useMemo, + useRef, + useState +} from 'react'; +// eslint-disable-next-line +import { + Direction, + Position, + TokenFieldSegment, + TokenSegment, + TokenSegmentList +} from './TokenSegmentList'; +import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {Group} from 'react-aria-components/Group'; +import {IconContext, mergeStyles, UnsafeStyles} from '@react-spectrum/s2'; +import {Image, Text} from '@react-spectrum/s2/Card'; +import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; +import {isFocusable} from 'react-aria/private/utils/isFocusable'; +import {Link} from '@react-spectrum/s2/Link'; +import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; +// eslint-disable-next-line +import Plus from '@react-spectrum/s2/icons/Add'; +import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; +import {positionToDOMRange, Token, TokenField, TokenProps} from './TokenField'; +import Send from '@react-spectrum/s2/icons/ArrowUpSend'; +import Stop from '@react-spectrum/s2/icons/StopProcessing'; + +interface Attachment { + id: string; + file: File; + image: string; +} + +interface PromptFieldProps extends UnsafeStyles { + children: React.ReactNode; + acceptedAttachmentTypes?: string[]; + onSubmit?: (prompt: TokenSegmentList, attachments: Attachment[]) => void; + isGenerating?: boolean; + onStop?: () => void; + onAddAttachments?: (attachments: Attachment[]) => void; + onRemoveAttachments?: (attachments: Attachment[]) => void; + styles?: StyleString; +} + +interface PromptFieldState { + attachments: Attachment[]; + setAttachments: React.Dispatch>; + acceptedAttachmentTypes?: string[]; + prompt: TokenSegmentList; + setPrompt: React.Dispatch>; + inputRef: React.RefObject; + onSubmit?: () => void; + onStop?: () => void; + isGenerating: boolean; + onAddAttachments?: (attachments: Attachment[]) => void; + onRemoveAttachments?: (attachments: Attachment[]) => void; +} + +// TODO: make this customizable +const tokenRegex = /(?<=\s|^)(https?:\/\/)?(www\.)?([^/\s]+\.[a-z]{2,}(\/\S+)?)(?=\s)/g; +class AutoLinkingSegmentList extends TokenSegmentList { + tokenize(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; + } +} + +const PromptFieldContext = createContext({ + attachments: [], + setAttachments: () => {}, + prompt: new AutoLinkingSegmentList([]), + setPrompt: () => {}, + inputRef: createRef(), + isGenerating: false +}); + +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 function PromptField(props: PromptFieldProps) { + let { + children, + acceptedAttachmentTypes, + isGenerating, + onStop, + UNSAFE_className = '', + UNSAFE_style, + styles, + onAddAttachments, + onRemoveAttachments + } = props; + let [prompt, setPrompt] = useState(new AutoLinkingSegmentList([])); + let [attachments, setAttachments] = useState([]); + + // 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 onSubmit = () => { + if (prompt.segments.length === 0) { + return; + } + + props.onSubmit?.(prompt, attachments); + setPrompt(new AutoLinkingSegmentList([])); + setAttachments([]); + inputRef.current?.focus(); + }; + + return ( + +
+ + UNSAFE_className + + mergeStyles( + style({ + display: 'flex', + flexDirection: 'column', + gap: 16, + padding: 16, + boxShadow: 'emphasized', + backgroundColor: { + default: 'elevated', + isDropTarget: 'blue-200' + }, + borderRadius: 'lg', + borderWidth: 2, + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isDropTarget: 'blue-800' + }, + cursor: 'text' + })({...renderProps, isDropTarget}), + styles + ) + } + onPointerDown={e => { + // If not clicking on something focusable within the prompt field, focus the input. + let target = getEventTarget(e) as Element | null; + while (target && target !== e.currentTarget && !isFocusable(target)) { + target = target.parentElement; + } + + if (target === e.currentTarget) { + e.preventDefault(); + inputRef.current?.focus(); + } + }}> + {children} + +

+ Responses are generated using AI, and may be inaccurate. Check before using.{' '} + + AI User Guidelines + +

+
+
+ ); +} + +interface PromptFieldAttachmentListProps extends AttachmentListProps { + children?: (attachment: Attachment) => React.ReactNode; +} + +export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) { + let {children} = props; + let {attachments, setAttachments, onRemoveAttachments, inputRef} = useContext(PromptFieldContext); + 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 && } + + ))} + + ); +} + +interface PromptTokenFieldProps { + completionTrigger?: RegExp; + renderCompletions?: ( + filterValue: string + ) => React.ReactNode[] | null | Promise; + children?: (segment: TokenSegment) => React.ReactElement; +} + +export function PromptTokenField(props: PromptTokenFieldProps) { + let {completionTrigger, renderCompletions, children} = props; + let { + prompt, + setPrompt, + acceptedAttachmentTypes, + setAttachments, + onAddAttachments, + inputRef, + onSubmit + } = useContext(PromptFieldContext); + let [isFocused, setFocused] = useState(false); + + let [filterAnchor, filterValue] = useMemo(() => { + if (completionTrigger) { + let filterAnchor = prompt.findText( + prompt.caretPosition, + 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 ( + + { + if (e.isTrusted) { + setFocused(true); + } + }} + onBlur={e => { + if (e.isTrusted) { + setFocused(false); + } + }} + onPaste={ + acceptedAttachmentTypes + ? e => { + let clipboardData = e.clipboardData as DataTransfer; + let attachments: Attachment[] = []; + 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 + } + onSubmit={onSubmit} + className={renderProps => + 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})} + + + + ); +} + +interface PromptTokenFieldPopoverProps extends PopoverProps { + filterAnchor?: Position | null; + items?: React.ReactNode[] | null | Promise; + isFocused?: boolean; +} + +function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { + let {filterAnchor, items, isFocused} = props; + let {inputRef, setPrompt} = 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); + } + + let onAction = (item: any) => { + setPrompt(value => + value.replaceRangeWithSegments( + filterAnchor!, + value.caretPosition, + [ + { + type: 'token', + text: 'command' in item ? item.command : item.title, + value: item + }, + {type: 'text', text: ' '} + ], + false // Don't coalesce in undo/redo history. + ) + ); + }; + + return ( + { + return positionToDOMRange(target, filterAnchor!).getBoundingClientRect(); + }}> + onAction(value)}>{menuItems} + + ); +} + +export interface PromptTokenProps extends Omit { + children: React.ReactNode; +} + +export function PromptToken(props: PromptTokenProps) { + return ( + + {icon}}}> + {props.children} + + + ); +} + +interface PromptFieldToolbarProps { + children: React.ReactNode; +} + +export function PromptFieldToolbar(props: PromptFieldToolbarProps) { + let {children} = props; + return ( +
+ {children} +
+ ); +} + +export function PromptFieldSubmitButton() { + let {prompt, isGenerating, onSubmit, onStop} = useContext(PromptFieldContext); + return ( + + ); +} + +interface InsertMenuItemProps { + children: React.ReactNode; +} + +export function InsertMenuButton(props: InsertMenuItemProps) { + let {children} = props; + 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 + + ); +} + +export function InsertTokenMenuItem(props: MenuItemProps) { + let {setPrompt, inputRef} = useContext(PromptFieldContext); + let onAction = (item: any) => { + setPrompt(value => + value.replaceRangeWithSegments( + value.caretPosition, + value.caretPosition, + [ + { + type: 'token', + text: 'command' in item ? item.command : item.title, + value: item + }, + {type: 'text', text: ' '} + ], + false // Don't coalesce in undo/redo history. + ) + ); + + // Wait for popover animation + setTimeout(() => { + inputRef.current?.focus(); + }, 400); + }; + + return onAction(props.value)} />; +} diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 1fd3b2ba6d5..13bcde2b384 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -15,6 +15,7 @@ import { baseColor, focusRing, iconStyle, + lightDark, space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -151,6 +152,7 @@ const buttonStyles = style({ }, color: { default: baseColor('neutral'), + isLoading: 'neutral', forcedColors: 'ButtonText', isDisabled: { default: 'disabled', @@ -195,7 +197,13 @@ const buttonStyles = style({ } }, width: 'full', - backgroundColor: 'transparent', + backgroundColor: { + default: 'transparent', + isFocusVisible: lightDark('transparent-black-100', 'transparent-white-100'), + isHovered: lightDark('transparent-black-100', 'transparent-white-100'), + isPressed: lightDark('transparent-black-300', 'transparent-white-300'), + isLoading: 'transparent' + }, transition: 'default', borderWidth: 0, borderRadius: 'default', @@ -256,7 +264,7 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( return (
- + { + setGenerating(false); + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + }}> +
+ + +
+
); @@ -490,13 +492,10 @@ export function VirtualizedThread() { let nextId = useRef(initialResponses.length); let lastMessage = messages.at(-1); let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; - function handleSend(text: string) { - if (!text.trim()) { - return; - } + function handleSend(prompt: TokenSegmentList) { setMessages(prev => [ ...prev, - {id: nextId.current++, type: 'user', content: text}, + {id: nextId.current++, type: 'user', content: prompt.toString()}, {id: nextId.current++, type: 'status', status: 'pending'} ]); setTimeout(() => { @@ -564,213 +563,16 @@ export function VirtualizedThread() { }} - - - ); -} - -// TODO: all of the below was copied from rsp-prototypes, just filler for now -// some modifications for streaming and what not -function PromptField({ - onSend, - isDisabled -}: { - onSend?: (text: string) => void; - isDisabled?: boolean; -}) { - let [text, setText] = useState(''); - let [attachments, setAttachments] = useState([ - { - image: 'https://react-spectrum.adobe.com/preview.c3b340d3.png', - title: 'Hilton assets', - description: '2026' - } - ]); - - // Not using RAC DropZone because it adds its own focusable button, - // and we want to avoid an extra tab stop by attaching to the input. - // TODO: support clipboard too (without messing up pasting text) - let inputRef = useRef(null); - let {dropProps, dropButtonProps, isDropTarget} = useDrop({ - ref: inputRef, - hasDropButton: true, - async onDrop(e) { - let files = await Promise.all( - e.items.filter(isFileDropItem).map(async item => ({ - image: item.type.startsWith('image/') ? URL.createObjectURL(await item.getFile()) : '', - title: item.name, - description: item.type - })) - ); - setAttachments(attachments => [...attachments, ...files]); - } - }); - - return ( -
- - style({ - ...focusRing(), - padding: 16, - boxShadow: 'emphasized', - backgroundColor: { - default: 'elevated', - isDropTarget: 'blue-200' - }, - borderRadius: 'lg', - borderWidth: 2, - borderStyle: 'solid', - borderColor: { - default: 'transparent', - isFocusWithin: 'gray-900', - isDropTarget: 'blue-800' - } - })({...renderProps, isDropTarget}) - }> - - {attachments.map((attachment, i) => ( - { - setAttachments(attachments.slice(0, i).concat(attachments.slice(i + 1))); - }} - /> - ))} - - setText(value)} slot="prompt"> - -