From ad9b3bb8152ca5945f6ab344934c7d018613bafd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 9 Jul 2026 10:31:50 -0700 Subject: [PATCH 01/25] expose size on MessageFeedback for Coworker other changes like gettring rid of the ToggleButtonGroup are deffered in favor of wrapping the group in a Toolbar --- .../@react-spectrum/ai/src/MessageFeedback.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/ai/src/MessageFeedback.tsx b/packages/@react-spectrum/ai/src/MessageFeedback.tsx index 2f692b858b8..a2dde90e5ad 100644 --- a/packages/@react-spectrum/ai/src/MessageFeedback.tsx +++ b/packages/@react-spectrum/ai/src/MessageFeedback.tsx @@ -20,13 +20,14 @@ import {StylesPropWithHeight} from '@react-spectrum/s2'; import ThumbDown from '@react-spectrum/s2/icons/ThumbDown'; import ThumbUp from '@react-spectrum/s2/icons/ThumbUp'; import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; -import {ToggleButtonGroup} from '@react-spectrum/s2/ToggleButtonGroup'; +import {ToggleButtonGroup, ToggleButtonGroupProps} from '@react-spectrum/s2/ToggleButtonGroup'; import {useDOMRef} from './useDOMRef'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; export type MessageFeedbackValue = 'up' | 'down' | null; -export interface MessageFeedbackProps extends DOMProps, AriaLabelingProps, SlotProps { +export interface MessageFeedbackProps + extends DOMProps, AriaLabelingProps, SlotProps, Pick { /** The selected feedback value (controlled). */ value?: MessageFeedbackValue; /** The default feedback value (uncontrolled). */ @@ -57,7 +58,16 @@ export const MessageFeedback = forwardRef(function MessageFeedback( ) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let domRef = useDOMRef(ref); - let {value, defaultValue, onChange, isDisabled, thumbUpLabel, thumbDownLabel, styles} = props; + let { + value, + defaultValue, + onChange, + isDisabled, + thumbUpLabel, + thumbDownLabel, + styles, + size = 'M' + } = props; let handleSelectionChange = (selection: Selection): void => { onChange?.(selectionToValue(selection)); @@ -76,6 +86,7 @@ export const MessageFeedback = forwardRef(function MessageFeedback( Date: Thu, 9 Jul 2026 15:15:16 -0700 Subject: [PATCH 02/25] localize scroll to bottom button, expose PromptFocusContext if users arent using our prompt field --- packages/@react-spectrum/ai/exports/index.ts | 10 ++++++++-- packages/@react-spectrum/ai/intl/en-US.json | 1 + packages/@react-spectrum/ai/src/Chat.tsx | 19 ++++++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 5e837d9bf92..1758b34b949 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -14,7 +14,7 @@ export { PromptToken } from '../src/PromptField'; export {ResponseStatus, ResponseStatusTitle, ResponseStatusPanel} from '../src/ResponseStatus'; -export {Chat, Thread, ThreadItem, ThreadScrollButton} from '../src/Chat'; +export {Chat, Thread, ThreadItem, ThreadScrollButton, PromptFocusContext} from '../src/Chat'; export {TokenSegmentList} from '../src/TokenSegmentList'; export {UserMessage} from '../src/UserMessage'; @@ -38,6 +38,12 @@ export type { ResponseStatusTitleProps, ResponseStatusPanelProps } from '../src/ResponseStatus'; -export type {ChatProps, ThreadProps, ThreadItemProps, ThreadScrollButtonProps} from '../src/Chat'; +export type { + ChatProps, + ThreadProps, + ThreadItemProps, + ThreadScrollButtonProps, + PromptFocusContextValue +} from '../src/Chat'; export type {TokenSegmentListOptions} from '../src/TokenSegmentList'; export type {UserMessageProps} from '../src/UserMessage'; diff --git a/packages/@react-spectrum/ai/intl/en-US.json b/packages/@react-spectrum/ai/intl/en-US.json index adc6cbede25..fee7d874f89 100644 --- a/packages/@react-spectrum/ai/intl/en-US.json +++ b/packages/@react-spectrum/ai/intl/en-US.json @@ -1,5 +1,6 @@ { "chat.newMessage": "New message", + "chat.scrollToBottom": "Scroll to bottom", "messagefeedback.thumbDown": "Bad response", "messagefeedback.thumbUp": "Good response", "responsestatus.loading": "Loading" diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 162837b1ba4..ca0f5608d40 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -83,11 +83,13 @@ const InternalChatContext = createContext({ interface ThreadScrollButtonContextValue { isNearBottom: boolean; scrollToBottom: () => void; + 'aria-label': string; } const ThreadScrollButtonContext = createContext({ isNearBottom: true, - scrollToBottom: () => {} + scrollToBottom: () => {}, + 'aria-label': '' }); // TODO: make this more RAC like (aka default class name and other RAC prop) @@ -185,7 +187,14 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( (null); let isVisible = !isNearBottom; let isExiting = useExitAnimation(ref, isVisible); @@ -300,7 +309,7 @@ export function ThreadScrollButton({children}: ThreadScrollButtonProps) { return ( + value={{slots: {[DEFAULT_SLOT]: {}, scroll: {onPress: scrollToBottom, ...buttonProps}}}}> {children} @@ -328,7 +337,7 @@ const threadItemBase = style({ borderRadius: 'default' }); -export interface ThreadItemProps extends Pick { +export interface ThreadItemProps extends Pick { /** * Spectrum-defined styles, returned by the `style()` macro. */ From 45b0864becc9a19bc6aa8f072ffddba4ae0a6c09 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 9 Jul 2026 15:56:53 -0700 Subject: [PATCH 03/25] add "failed" Response status and variant where the response doesnt have a panel to display --- .../@react-spectrum/ai/src/ResponseStatus.tsx | 176 +++++++++++------- .../ai/stories/ResponseStatus.stories.tsx | 29 ++- 2 files changed, 137 insertions(+), 68 deletions(-) diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 9e0a66cc424..172c4ea0bf5 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -23,6 +23,7 @@ import {Button} from 'react-aria-components/Button'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import CheckmarkCircle from '@react-spectrum/s2/icons/CheckmarkCircle'; import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; +import CloseCircle from '@react-spectrum/s2/icons/CloseCircle'; import { DisclosureStateContext, Disclosure as RACDisclosure, @@ -38,11 +39,20 @@ import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {Provider} from 'react-aria-components/slots'; -import React, {createContext, forwardRef, ReactNode, useContext} from 'react'; +import React, { + createContext, + forwardRef, + ReactNode, + useCallback, + useContext, + useLayoutEffect, + useState +} from 'react'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; + export interface ResponseStatusProps extends Omit< RACDisclosureProps, 'className' | 'style' | 'render' | 'children' | keyof GlobalDOMAttributes @@ -60,10 +70,11 @@ export interface ResponseStatusProps extends Omit< */ density?: 'compact' | 'regular' | 'spacious'; /** - * Whether the response is still being generated. When true, a ProgressCircle replaces - * the chevron and the panel cannot be expanded. The trigger remains focusable. + * The current status of the response. + * + * @default 'loading' */ - isLoading?: boolean; + status?: 'loading' | 'failed' | 'success'; /** * The contents of the response status, consisting of a ResponseStatusTitle and * ResponseStatusPanel. @@ -78,8 +89,14 @@ export interface ResponseStatusProps extends Omit< const ResponseStatusContext = createContext<{ size?: 'S' | 'M' | 'L' | 'XL'; density?: 'compact' | 'regular' | 'spacious'; - isLoading?: boolean; -}>({}); + status: 'loading' | 'failed' | 'success'; + hasPanelContent: boolean; + registerPanel: (mounted: boolean) => void; +}>({ + status: 'loading', + hasPanelContent: false, + registerPanel: () => {} +}); const responseStatus = style({ color: 'heading', @@ -87,24 +104,28 @@ const responseStatus = style({ }); /** - * A ResponseStatus indicates the progress of a system response while it is begin generated and when - * it is complete. + * A ResponseStatus indicates the progress of a system response while it is being generated and when + * it is complete. If a ResponseStatusPanel is provided, the title can be pressed to expand and + * collapse it. */ export const ResponseStatus = forwardRef(function ResponseStatus( props: ResponseStatusProps, ref: DOMRef ) { - let {size = 'M', density = 'regular', isLoading, styles} = props; + let {size = 'M', density = 'regular', status = 'loading', styles} = props; let domRef = useDOMRef(ref); + let [hasPanelContent, setHasPanelContent] = useState(false); + let registerPanel = useCallback((mounted: boolean) => setHasPanelContent(mounted), []); let disclosureProps: Partial = {}; - if (isLoading) { + if (status === 'loading') { disclosureProps.isExpanded = false; disclosureProps.onExpandedChange = () => {}; } return ( - + - ); @@ -352,16 +394,22 @@ const panelInner = style({ /** * A response status panel is a collapsible section of content that is hidden until the - * response status is expanded. The panel cannot be expanded while `isLoading` is true. + * response status is expanded. The panel cannot be expanded while `status` is `'loading'`. */ export const ResponseStatusPanel = forwardRef(function ResponseStatusPanel( props: ResponseStatusPanelProps, ref: DOMRef ) { let {styles} = props; - let {size = 'M'} = useContext(ResponseStatusContext)!; + let {size = 'M', registerPanel} = useContext(ResponseStatusContext)!; const domProps = filterDOMProps(props); let panelRef = useDOMRef(ref); + + useLayoutEffect(() => { + registerPanel(true); + return () => registerPanel(false); + }, [registerPanel]); + return (
{props.children}
diff --git a/packages/@react-spectrum/ai/stories/ResponseStatus.stories.tsx b/packages/@react-spectrum/ai/stories/ResponseStatus.stories.tsx index 3b68f172946..18b4f2b8544 100644 --- a/packages/@react-spectrum/ai/stories/ResponseStatus.stories.tsx +++ b/packages/@react-spectrum/ai/stories/ResponseStatus.stories.tsx @@ -34,13 +34,14 @@ const meta: Meta = { control: 'radio', options: ['compact', 'regular', 'spacious'] }, - isLoading: { - control: {type: 'boolean'} + status: { + control: 'radio', + options: ['loading', 'failed', 'success'] }, children: {table: {disable: true}} }, args: { - isLoading: true, + status: 'loading', ...getActionArgs(events) }, title: 'AI/ResponseStatus' @@ -54,7 +55,11 @@ export const Example: Story = {
- {args.isLoading ? 'Generating response' : 'Response generated'} + {args.status === 'loading' + ? 'Generating response' + : args.status === 'success' + ? 'Response generated' + : 'Response failed'} Here is the generated response content. This area is hidden until the disclosure is @@ -64,3 +69,19 @@ export const Example: Story = {
) }; + +export const NoResponseContent: Story = { + render: args => ( +
+ + + {args.status === 'loading' + ? 'Generating response' + : args.status === 'success' + ? 'Response generated' + : 'Response failed'} + + +
+ ) +}; From f301a021dcdcc0c6d55d6d23b4ac47cabccbe36e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 10 Jul 2026 11:50:52 -0700 Subject: [PATCH 04/25] expose isDisabled/placeholder and add onKeydown --- .../@react-spectrum/ai/src/PromptField.tsx | 58 +++++++++++++++---- .../@react-spectrum/ai/src/TokenField.tsx | 3 + .../ai/stories/Chat.stories.tsx | 33 ++++++++++- .../ai/stories/PromptField.stories.tsx | 49 +++++++++++----- 4 files changed, 117 insertions(+), 26 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 7614bcae7d3..ce1c7227414 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -63,6 +63,7 @@ export interface PromptFieldProps { acceptedAttachmentTypes?: string[]; onSubmit?: (prompt: TokenSegmentList, attachments: PromptFieldAttachment[]) => void; isGenerating?: boolean; + isDisabled?: boolean; onStop?: () => void; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; @@ -79,6 +80,7 @@ interface PromptFieldState { onSubmit?: () => void; onStop?: () => void; isGenerating: boolean; + isDisabled: boolean; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; } @@ -118,7 +120,8 @@ const PromptFieldContext = createContext({ prompt: new AutoLinkingSegmentList([]), setPrompt: () => {}, inputRef: createRef(), - isGenerating: false + isGenerating: false, + isDisabled: false }); function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { @@ -138,6 +141,7 @@ export function PromptField(props: PromptFieldProps) { children, acceptedAttachmentTypes, isGenerating, + isDisabled, onStop, styles, onAddAttachments, @@ -176,7 +180,7 @@ export function PromptField(props: PromptFieldProps) { let {focusWithinProps} = useFocusWithin({onFocusWithinChange: onFocusChange}); let onSubmit = () => { - if (prompt.segments.length === 0) { + if (prompt.segments.length === 0 || isDisabled) { return; } @@ -197,6 +201,7 @@ export function PromptField(props: PromptFieldProps) { inputRef, onSubmit, isGenerating: isGenerating ?? false, + isDisabled: isDisabled ?? false, onStop, onAddAttachments, onRemoveAttachments @@ -204,6 +209,7 @@ export function PromptField(props: PromptFieldProps) {
mergeStyles( @@ -223,8 +229,12 @@ export function PromptField(props: PromptFieldProps) { borderColor: { default: 'transparent', isDropTarget: 'blue-800' + // TODO: coworker's disabled style here is still transparent }, - cursor: 'text' + cursor: { + default: 'text', + isDisabled: 'default' + } })({...renderProps, isDropTarget}), styles ) @@ -297,10 +307,20 @@ export interface PromptTokenFieldProps { filterValue: string ) => React.ReactNode[] | null | Promise; children?: (segment: TokenSegment) => React.ReactElement; + placeholder?: string; + isDisabled?: boolean; + onKeyDown?: (e: React.KeyboardEvent) => void; } export function PromptTokenField(props: PromptTokenFieldProps) { - let {completionTrigger, renderCompletions, children} = props; + let { + completionTrigger, + renderCompletions, + children, + placeholder, + isDisabled: isDisabledProp, + onKeyDown + } = props; let { prompt, setPrompt, @@ -308,8 +328,10 @@ export function PromptTokenField(props: PromptTokenFieldProps) { setAttachments, onAddAttachments, inputRef, - onSubmit + onSubmit, + isDisabled: isDisabledContext } = useContext(PromptFieldContext); + let isDisabled = isDisabledProp || isDisabledContext; let [isFocused, setFocused] = useState(false); let [filterAnchor, filterValue] = useMemo(() => { @@ -338,7 +360,11 @@ export function PromptTokenField(props: PromptTokenFieldProps) { onChange={setPrompt} multiline aria-label="Prompt" - data-placeholder="Ready to get started? Ask a question, share an idea, or add a task." + data-placeholder={ + placeholder ?? 'Ready to get started? Ask a question, share an idea, or add a task.' + } + isDisabled={isDisabled} + onKeyDown={onKeyDown} ref={inputRef} onFocus={e => { if (e.isTrusted) { @@ -379,14 +405,25 @@ export function PromptTokenField(props: PromptTokenFieldProps) { font: 'body', color: { default: baseColor('neutral'), + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + }, ':empty': { default: 'gray-600', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + }, forcedColors: 'GrayText' } }, width: 'full', outlineStyle: 'none', - cursor: 'text' + cursor: { + default: 'text', + isDisabled: 'default' + } })(renderProps) }> {children || (segment => {segment.text})} @@ -515,12 +552,12 @@ 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 {prompt, isGenerating, isDisabled, onSubmit, onStop} = useContext(PromptFieldContext); return (
{ + setPromptValue(new AutoLinkingSegmentList([])); + handleSend(prompt); + }} isGenerating={isGenerating} onStop={() => { setGenerating(false); @@ -562,14 +578,21 @@ function StreamingChatRender() { return; } + // TODO: we could make this even more realistic but for now just fire storybook event + // and add follow up message to queue if (e.key === 'Enter' && !e.altKey) { e.preventDefault(); - // TODO: implement steering, also not sure why action wasn't working... - console.log('calling steer'); + if (promptValue.segments.length > 0) { + action('onSteer')(promptValue.toString()); + setPromptValue(new AutoLinkingSegmentList([])); + } } else if (e.key === 'Enter' && e.altKey) { e.preventDefault(); - // TODO: implement followup - console.log('calling followup'); + if (promptValue.segments.length > 0) { + action('onFollowUp')(promptValue.toString()); + followUpMessage.current = promptValue; + setPromptValue(new AutoLinkingSegmentList([])); + } } }} /> diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 7bf9483cd42..32f09f5cc49 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -10,17 +10,22 @@ * governing permissions and limitations under the License. */ +import {action} from 'storybook/actions'; import { AttachFileMenuItem, + AutoLinkingSegmentList, InsertMenuButton, InsertTokenMenuItem, PromptField, + PromptFieldAttachment, PromptFieldAttachmentList, PromptFieldSubmitButton, PromptFieldToolbar, PromptToken, PromptTokenField } from '../src/PromptField'; +import {TokenSegmentList} from '../src/TokenSegmentList'; +import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {Attachment} from '../src/AttachmentList'; import Brand from '@react-spectrum/s2/icons/Brand'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; @@ -178,8 +183,31 @@ interface UploadState { progress?: number; } +let prompts = [ + new AutoLinkingSegmentList([ + {type: 'text', text: 'Analyze '}, + {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, + {type: 'text', text: ' and suggest targeting strategies'} + ]), + new AutoLinkingSegmentList([ + {type: 'text', text: 'Write a brief for '}, + { + type: 'token', + text: 'Spring Launch 2026', + value: {type: 'campaign', title: 'Spring Launch 2026'} + } + ]), + new AutoLinkingSegmentList([ + {type: 'text', text: 'Summarize the '}, + {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}}, + {type: 'text', text: ' journey performance https://test.com '} + ]) +]; + function EverythingRender(args) { let {placeholder, ...otherArgs} = args; + let [value, setValue] = useState(() => new AutoLinkingSegmentList([])); + let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); let mockUpload = async (id: string) => { @@ -202,106 +230,125 @@ function EverythingRender(args) { }; return ( - { - setAttachmentState(prev => { - let newState = new Map(prev); - attachments.forEach(attachment => { - newState.set(attachment.id, {status: 'uploading', progress: 0}); - mockUpload(attachment.id); - }); - return newState; - }); - }} - onRemoveAttachments={attachments => { - setAttachmentState(prev => { - let newState = new Map(prev); - attachments.forEach(attachment => { - newState.delete(attachment.id); +
+
+ {prompts.map((prompt, i) => ( + setValue(prompt)}> + {prompt.toString()} + + ))} +
+ { + action('onSubmit')(prompt.toString()); + setValue(new AutoLinkingSegmentList([])); + setAttachments([]); + setAttachmentState(new Map()); + }} + acceptedAttachmentTypes={['image/*']} + onAddAttachments={newAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + newAttachments.forEach(attachment => { + newState.set(attachment.id, {status: 'uploading', progress: 0}); + mockUpload(attachment.id); + }); + return newState; }); - return newState; - }); - }}> - - {attachment => { - let state = attachmentState.get(attachment.id); - return ( - - {/* TODO: what about non-image attachments? */} - {attachment.image && } - - ); }} - - - {segment => ( - - {icons[segment.value?.type]} - {segment.text} - - )} - - - - - - - - Commands - - item.type === 'command')}> - {item => ( - - {item.command} - {item.description} - - )} - - - - - - Skills - - item.type === 'skill')}> - {item => ( - - {item.command} - {item.description} - - )} - - - - - - Reference an object - - - {item => ( - -
- {item.section} -
- - {item => ( - {item.title} - )} - -
- )} -
-
-
- -
-
+ onRemoveAttachments={removedAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + removedAttachments.forEach(attachment => { + newState.delete(attachment.id); + }); + return newState; + }); + }}> + + {attachment => { + let state = attachmentState.get(attachment.id); + return ( + + {/* TODO: what about non-image attachments? */} + {attachment.image && } + + ); + }} + + + {segment => ( + + {icons[segment.value?.type]} + {segment.text} + + )} + + + + + + + + Commands + + item.type === 'command')}> + {item => ( + + {item.command} + {item.description} + + )} + + + + + + Skills + + item.type === 'skill')}> + {item => ( + + {item.command} + {item.description} + + )} + + + + + + Reference an object + + + {item => ( + +
+ {item.section} +
+ + {item => ( + {item.title} + )} + +
+ )} +
+
+
+ +
+ +
); } From 0725b7129a59a5b3948df8bf59b932f5ce231946 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 10 Jul 2026 14:22:51 -0700 Subject: [PATCH 06/25] have it auto tokenize urls from controlled value insertions and fix cursor positioning when token added via + menu also support escape /click on stop button to stop streaming in chat story --- .../@react-spectrum/ai/src/PromptField.tsx | 77 ++++++++++++------- .../ai/stories/Chat.stories.tsx | 24 ++++-- .../ai/stories/PromptField.stories.tsx | 3 +- 3 files changed, 72 insertions(+), 32 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 9ebe0d068d7..aa00cc071ea 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -33,7 +33,8 @@ import { Position, TokenFieldSegment, TokenSegment, - TokenSegmentList + TokenSegmentList, + TokenSegmentListOptions } from './TokenSegmentList'; import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {Group} from 'react-aria-components/Group'; @@ -46,7 +47,7 @@ import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Men // 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 {positionToDOMRange, setCursor, Token, TokenField, TokenProps} from './TokenField'; import {PromptFocusContext} from './Chat'; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import Stop from '@react-spectrum/s2/icons/StopProcessing'; @@ -96,30 +97,47 @@ interface PromptFieldState { // TODO: make this customizable const tokenRegex = /(?<=\s|^)(https?:\/\/)?(www\.)?([^/\s]+\.[a-z]{2,}(\/\S+)?)(?=\s)/g; -export class AutoLinkingSegmentList extends TokenSegmentList { - tokenize(text: string): TokenFieldSegment[] { - if (text.length === 0) { - return [{type: 'text', text}]; - } +function tokenizeURLs(text: string): TokenFieldSegment[] { + if (text.length === 0) { + return [{type: 'text', text}]; + } - tokenRegex.lastIndex = 0; + 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; + 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; +} - if (start < text.length) { - segments.push({type: 'text', text: text.slice(start)}); +export class AutoLinkingSegmentList extends TokenSegmentList { + // attempt to convert any text to url tokens if any + constructor(tokens: readonly TokenFieldSegment[], options?: TokenSegmentListOptions) { + let processedTokens: TokenFieldSegment[] = []; + for (let seg of tokens) { + if (seg.type === 'text') { + processedTokens.push(...tokenizeURLs(seg.text)); + } else { + processedTokens.push(seg); + } } + super(processedTokens, options); + } - return segments; + tokenize(text: string): TokenFieldSegment[] { + return tokenizeURLs(text); } } @@ -642,9 +660,10 @@ export function AttachFileMenuItem() { export function InsertTokenMenuItem(props: MenuItemProps) { let {setPrompt, inputRef} = useContext(PromptFieldContext); + let pendingCaret = useRef(null); let onAction = (item: any) => { - setPrompt(value => - value.replaceRangeWithSegments( + setPrompt(value => { + let newValue = value.replaceRangeWithSegments( value.caretPosition, value.caretPosition, [ @@ -656,12 +675,18 @@ export function InsertTokenMenuItem(props: MenuItemProps) { {type: 'text', text: ' '} ], false // Don't coalesce in undo/redo history. - ) - ); + ); + pendingCaret.current = newValue.caretPosition; + return newValue; + }); - // Wait for popover animation + // Wait for popover animation, then restore cursor to after the inserted token setTimeout(() => { - inputRef.current?.focus(); + if (inputRef.current && pendingCaret.current) { + inputRef.current.focus(); + setCursor(inputRef.current, pendingCaret.current); + pendingCaret.current = null; + } }, 400); }; diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index ba874c6429f..c0e5d7488cb 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -230,6 +230,21 @@ function StreamingChatRender() { } }, [isGenerating]); + // TODO: maybe also have it finalize any in progress tool calls and what not, but do it later + function handleStop() { + followUpMessage.current = null; + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + setMessages(prev => + prev.map(m => + (m.type === 'system' || m.type === 'status') && m.isStreaming + ? {...m, isStreaming: false} + : m + ) + ); + setGenerating(false); + } + function handleSend(prompt: TokenSegmentList) { setGenerating(true); // user message added first so its announcement plays before @@ -561,11 +576,7 @@ function StreamingChatRender() { handleSend(prompt); }} isGenerating={isGenerating} - onStop={() => { - setGenerating(false); - timeouts.current.forEach(clearTimeout); - timeouts.current = []; - }}> + onStop={handleStop}>
diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 32f09f5cc49..e5640cdcb99 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -200,7 +200,8 @@ let prompts = [ new AutoLinkingSegmentList([ {type: 'text', text: 'Summarize the '}, {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}}, - {type: 'text', text: ' journey performance https://test.com '} + // TODO: note this needs a space after test.com for it to be autotokenized + {type: 'text', text: ' journey performance from test.com '} ]) ]; From 89b4a0d0717d9e6b1fa542ce7193d161dcc15a35 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 10 Jul 2026 16:34:12 -0700 Subject: [PATCH 07/25] add plain text insertion menu item --- packages/@react-spectrum/ai/exports/index.ts | 1 + .../@react-spectrum/ai/src/PromptField.tsx | 88 +++++++++-------- .../ai/stories/PromptField.stories.tsx | 96 +++++++++++++++---- 3 files changed, 127 insertions(+), 58 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index c6014cd24d4..26922b16e0d 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -9,6 +9,7 @@ export { PromptTokenField, AttachFileMenuItem, InsertMenuButton, + InsertTextMenuItem, InsertTokenMenuItem, PromptFieldAttachmentList, PromptFieldToolbar, diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index aa00cc071ea..d9769cf2862 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -151,6 +151,11 @@ const PromptFieldContext = createContext({ isDisabled: false }); +// 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 === '*/*') { @@ -486,7 +491,7 @@ export interface PromptTokenFieldPopoverProps extends PopoverProps { function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { let {filterAnchor, items, isFocused} = props; - let {inputRef, setPrompt} = useContext(PromptFieldContext); + let {inputRef} = useContext(PromptFieldContext); let resolvedItems = items instanceof Promise ? use(items) : items; let isOpen = @@ -498,24 +503,6 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { 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} + + {menuItems} + ); } @@ -658,37 +647,56 @@ export function AttachFileMenuItem() { ); } -export function InsertTokenMenuItem(props: MenuItemProps) { +// 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); - let onAction = (item: any) => { + return (item: any) => { setPrompt(value => { let newValue = value.replaceRangeWithSegments( + anchor ?? value.caretPosition, value.caretPosition, - value.caretPosition, - [ - { - type: 'token', - text: 'command' in item ? item.command : item.title, - value: item - }, - {type: 'text', text: ' '} - ], + buildSegments(item), false // Don't coalesce in undo/redo history. ); pendingCaret.current = newValue.caretPosition; return newValue; }); - // Wait for popover animation, then restore cursor to after the inserted token - setTimeout(() => { - if (inputRef.current && pendingCaret.current) { - inputRef.current.focus(); - setCursor(inputRef.current, pendingCaret.current); - pendingCaret.current = null; - } - }, 400); + 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(); + setCursor(inputRef.current, position); + // TODO: double check this, claude debugged this one, but essentially reproduced with plain text insertion commands + // triggered one after another + // focus() fires a synchronous selectionchange before setCursor can set + // isProgrammaticSelectionChange, which resets caretPosition to {0,0} in + // TokenField's useSelectionChange handler. Re-assert the correct position. + setPrompt(value => value.withCaretPosition(position)); + } + }, 400); + } }; +} + +export function InsertTokenMenuItem(props: MenuItemProps) { + let insert = useInsertPromptSegment(item => [ + {type: 'token', text: 'command' in item ? item.command : item.title, value: item}, + {type: 'text', text: ' '} + ]); + + return insert(props.value)} />; +} + +export function InsertTextMenuItem(props: MenuItemProps) { + let insert = useInsertPromptSegment(item => [ + {type: 'text', text: `${'command' in item ? item.command : item.title} `} + ]); - return onAction(props.value)} />; + return insert(props.value)} />; } diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index e5640cdcb99..30715faf779 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -15,6 +15,7 @@ import { AttachFileMenuItem, AutoLinkingSegmentList, InsertMenuButton, + InsertTextMenuItem, InsertTokenMenuItem, PromptField, PromptFieldAttachment, @@ -89,9 +90,11 @@ const slashCommands = [ type: 'skill', description: 'Explain an AEP audience in english' }, + {command: '/btw', type: 'command', description: 'Ask a side question'}, {command: '/clear', type: 'command', description: 'Clear the context'}, {command: '/compact', type: 'command', description: 'Summarize conversation history'}, {command: '/dataset-usage', type: 'skill', description: 'Explain how to use a dataset'}, + {command: '/feedback', type: 'command', description: 'Submit feedback'}, {command: '/plan', type: 'command', description: 'Create a plan before executing'}, {command: '/visual-artifact', type: 'skill', description: 'Generate a chart or graph'} ]; @@ -138,26 +141,52 @@ const objects = [ } ]; -function renderCompletions(filterValue: string) { +interface CompletionCallbacks { + onClear?: () => void; + onCompact?: () => void; +} + +function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) { if (filterValue.startsWith('/')) { return slashCommands .filter(item => item.command.includes(filterValue.slice(1))) - .map(item => ( - - {item.type === 'skill' ? : } - {item.command} - {item.description} - - )); + .map(item => + item.command === '/clear' ? ( + + + {item.command} + {item.description} + + ) : item.command === '/compact' ? ( + + + {item.command} + {item.description} + + ) : item.command === '/feedback' || item.command === '/btw' ? ( + // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing + + + {item.command} + {item.description} + + ) : ( + + {item.type === 'skill' ? : } + {item.command} + {item.description} + + ) + ); } else if (filterValue.startsWith('@')) { return objects .map(section => { let matchingItems = section.items .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) .map(item => ( - + {item.title} - + )); if (matchingItems.length > 0) { @@ -285,7 +314,16 @@ function EverythingRender(args) { + renderCompletions(filterValue, { + onClear: () => { + setValue(new AutoLinkingSegmentList([])); + setAttachments([]); + }, + // TODO: since this ends up being called be a normal menu item, it ends up not closing the autocomplete menu... + onCompact: action('onCompact') + }) + } placeholder={placeholder}> {segment => ( @@ -303,12 +341,34 @@ function EverythingRender(args) { Commands item.type === 'command')}> - {item => ( - - {item.command} - {item.description} - - )} + {item => + item.command === '/clear' ? ( + { + setValue(new AutoLinkingSegmentList([])); + setAttachments([]); + }}> + {item.command} + {item.description} + + ) : item.command === '/compact' ? ( + + {item.command} + {item.description} + + ) : item.command === '/feedback' || item.command === '/btw' ? ( + + {item.command} + {item.description} + + ) : ( + + {item.command} + {item.description} + + ) + } @@ -318,7 +378,7 @@ function EverythingRender(args) { item.type === 'skill')}> {item => ( - + {item.command} {item.description} From 7789775d0c9aa86fb2b6d59572fb5c5116296784 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 10 Jul 2026 16:53:53 -0700 Subject: [PATCH 08/25] add callback menu item this allows a callback (aka like "compact") command to clear the user partial filter text and close the autocomplete menu in the field --- packages/@react-spectrum/ai/exports/index.ts | 1 + packages/@react-spectrum/ai/src/PromptField.tsx | 16 ++++++++++++++++ .../ai/stories/PromptField.stories.tsx | 12 +++++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 26922b16e0d..b2aa9e5b317 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -8,6 +8,7 @@ export { PromptFieldSubmitButton, PromptTokenField, AttachFileMenuItem, + InsertCallbackMenuItem, InsertMenuButton, InsertTextMenuItem, InsertTokenMenuItem, diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index d9769cf2862..4a44828726c 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -700,3 +700,19 @@ export function InsertTextMenuItem(props: MenuItemProps) { return insert(props.value)} />; } + +// 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 InsertCallbackMenuItem(props: MenuItemProps) { + let insert = useInsertPromptSegment(() => []); + return ( + { + insert(undefined); + props.onAction?.(); + }} + /> + ); +} diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 30715faf779..00868084bfd 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -14,6 +14,7 @@ import {action} from 'storybook/actions'; import { AttachFileMenuItem, AutoLinkingSegmentList, + InsertCallbackMenuItem, InsertMenuButton, InsertTextMenuItem, InsertTokenMenuItem, @@ -158,11 +159,14 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) {item.description} ) : item.command === '/compact' ? ( - + {item.command} {item.description} - + ) : item.command === '/feedback' || item.command === '/btw' ? ( // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing @@ -320,7 +324,6 @@ function EverythingRender(args) { setValue(new AutoLinkingSegmentList([])); setAttachments([]); }, - // TODO: since this ends up being called be a normal menu item, it ends up not closing the autocomplete menu... onCompact: action('onCompact') }) } @@ -353,6 +356,9 @@ function EverythingRender(args) { {item.description} ) : item.command === '/compact' ? ( + // TODO: technically can be a standard menu item since triggering this action + // from the + menu means no partial text to clear, but maybe we can just standardize + // InsertCallbackMenuItem as the "basic" menu item for prompt field {item.command} {item.description} From fd1b8788031b95e72a74b4282c48567f147dc22b Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 14 Jul 2026 11:40:58 -0700 Subject: [PATCH 09/25] add voice input support to prompt field --- packages/@react-spectrum/ai/intl/en-US.json | 4 +- .../@react-spectrum/ai/src/PromptField.tsx | 133 +++++++- .../ai/src/speech-recognition.d.ts | 64 ++++ .../@react-spectrum/ai/src/useVoiceInput.ts | 292 ++++++++++++++++++ .../ai/stories/PromptField.stories.tsx | 7 +- 5 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 packages/@react-spectrum/ai/src/speech-recognition.d.ts create mode 100644 packages/@react-spectrum/ai/src/useVoiceInput.ts diff --git a/packages/@react-spectrum/ai/intl/en-US.json b/packages/@react-spectrum/ai/intl/en-US.json index fee7d874f89..4e2d94021f1 100644 --- a/packages/@react-spectrum/ai/intl/en-US.json +++ b/packages/@react-spectrum/ai/intl/en-US.json @@ -3,5 +3,7 @@ "chat.scrollToBottom": "Scroll to bottom", "messagefeedback.thumbDown": "Bad response", "messagefeedback.thumbUp": "Good response", - "responsestatus.loading": "Loading" + "responsestatus.loading": "Loading", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording" } diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 4a44828726c..3dc92d29506 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -11,7 +11,10 @@ */ import {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; +import {Tooltip, TooltipTrigger} from '@react-spectrum/s2/Tooltip'; import Attach from '@react-spectrum/s2/icons/Attach'; +import Microphone from '@react-spectrum/s2/icons/Microphone'; 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'}; @@ -23,6 +26,7 @@ import { use, useContext, useDeferredValue, + useEffect, useMemo, useRef, useState @@ -40,6 +44,8 @@ import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {Group} from 'react-aria-components/Group'; import {IconContext, mergeStyles} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; import {isFocusable} from 'react-aria/private/utils/isFocusable'; import {Link} from '@react-spectrum/s2/Link'; @@ -52,7 +58,10 @@ import {PromptFocusContext} from './Chat'; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import Stop from '@react-spectrum/s2/icons/StopProcessing'; import {useControlledState} from 'react-stately/useControlledState'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusWithin} from 'react-aria/useFocusWithin'; +import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; export interface PromptFieldAttachment { id: string; @@ -93,6 +102,8 @@ interface PromptFieldState { isDisabled: boolean; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; + isListening: boolean; + setListening: React.Dispatch>; } // TODO: make this customizable @@ -148,7 +159,9 @@ const PromptFieldContext = createContext({ setPrompt: () => {}, inputRef: createRef(), isGenerating: false, - isDisabled: false + isDisabled: false, + isListening: false, + setListening: () => {} }); // to communicate the anchor position to the menu items in the completion popover @@ -216,6 +229,7 @@ export function PromptField(props: PromptFieldProps) { } }); + let [isListening, setListening] = useState(false); let {onFocusChange} = useContext(PromptFocusContext); let {focusWithinProps} = useFocusWithin({onFocusWithinChange: onFocusChange}); @@ -248,6 +262,8 @@ export function PromptField(props: PromptFieldProps) { onSubmit, isGenerating: isGenerating ?? false, isDisabled: isDisabled ?? false, + isListening, + setListening, onStop, onAddAttachments, onRemoveAttachments @@ -375,7 +391,8 @@ export function PromptTokenField(props: PromptTokenFieldProps) { onAddAttachments, inputRef, onSubmit, - isDisabled: isDisabledContext + isDisabled: isDisabledContext, + isListening } = useContext(PromptFieldContext); let isDisabled = isDisabledProp || isDisabledContext; let [isFocused, setFocused] = useState(false); @@ -410,6 +427,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { placeholder ?? 'Ready to get started? Ask a question, share an idea, or add a task.' } isDisabled={isDisabled} + isReadOnly={isListening} onKeyDown={onKeyDown} ref={inputRef} onFocus={e => { @@ -595,6 +613,117 @@ export function PromptFieldSubmitButton(props: PromptFieldSubmitButtonProps) { ); } +export interface PromptFieldVoiceButtonProps { + lang?: string; + isDisabled?: boolean; + // TODO: coworker renders a toast too, but this gives more flexibility + onError?: (code: VoiceInputErrorCode) => void; +} + +export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { + let {lang, isDisabled: isDisabledProp, onError} = props; + let { + prompt, + setPrompt, + inputRef, + isDisabled: isDisabledContext, + setListening + } = useContext(PromptFieldContext); + let isDisabled = isDisabledProp ?? isDisabledContext; + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); + + let basePromptRef = useRef(prompt); + let updateBasePrompt = useEffectEvent(() => { + basePromptRef.current = prompt; + }); + + let { + isSupported, + isListening: isVoiceListening, + errorCode, + transcript, + toggle, + stop + } = useVoiceInput({lang}); + + 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(); + setCursor(inputRef.current, finalPrompt.caretPosition); + setPrompt(finalPrompt); + }); + + let wasListeningRef = useRef(false); + useEffect(() => { + if (isVoiceListening) { + updateBasePrompt(); + wasListeningRef.current = true; + } else if (wasListeningRef.current) { + wasListeningRef.current = false; + restoreFocus(); + } + setListening(isVoiceListening); + }, [isVoiceListening, setListening]); + + useEffect(() => { + if (!transcript || !isVoiceListening) { + return; + } + setPrompt(buildVoicePrompt(basePromptRef.current, transcript)); + }, [transcript, isVoiceListening, setPrompt]); + + // TODO this is from coworker, is to stop mutating the input text since the button becomes disabled when the chat + // is streaming, keep it? + useEffect(() => { + if (isDisabled && isVoiceListening) { + stop(); + } + }, [isDisabled, isVoiceListening, stop]); + + useEffect(() => { + if (errorCode) { + onError?.(errorCode); + } + }, [errorCode, onError]); + + if (!isSupported) { + return null; + } + + let label = isVoiceListening + ? stringFormatter.format('voicebutton.stopListening') + : stringFormatter.format('voicebutton.startListening'); + + return ( + + + + + {label} + + ); +} + +function buildVoicePrompt(base: TokenSegmentList, voiceText: string): AutoLinkingSegmentList { + if (!voiceText) { + return base as AutoLinkingSegmentList; + } + let segments: TokenFieldSegment[] = [...base.segments, {type: 'text', text: voiceText}]; + return new AutoLinkingSegmentList(segments, { + caretPosition: {index: segments.length - 1, offset: voiceText.length} + }); +} + export interface InsertMenuItemProps { children: React.ReactNode; } diff --git a/packages/@react-spectrum/ai/src/speech-recognition.d.ts b/packages/@react-spectrum/ai/src/speech-recognition.d.ts new file mode 100644 index 00000000000..b2a727eb134 --- /dev/null +++ b/packages/@react-spectrum/ai/src/speech-recognition.d.ts @@ -0,0 +1,64 @@ +/* + * 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. + */ + +// Ambient declarations for parts of the Web Speech API missing from TypeScript's +// lib.dom.d.ts: the recognizer constructor, its vendor-prefixed alias, event +// types, and the User-Agent Client Hints surface used for the Chromium browser +// allowlist. SpeechRecognitionAlternative, SpeechRecognitionResult, and +// SpeechRecognitionResultList ARE provided by lib.dom and are not redeclared. + +interface SpeechRecognitionEvent extends Event { + readonly resultIndex: number; + readonly results: SpeechRecognitionResultList; +} + +interface SpeechRecognitionErrorEvent extends Event { + readonly error: string; + readonly message: string; +} + +interface SpeechRecognition extends EventTarget { + lang: string; + continuous: boolean; + interimResults: boolean; + maxAlternatives: number; + start(): void; + stop(): void; + abort(): void; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; + onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; + onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; +} + +interface SpeechRecognitionConstructor { + new(): SpeechRecognition; +} + +interface Window { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; +} + +// User-Agent Client Hints — present in Chromium, absent in Safari/Firefox. +interface NavigatorUABrandVersion { + readonly brand: string; + readonly version: string; +} + +interface NavigatorUAData { + readonly brands: ReadonlyArray; +} + +interface Navigator { + readonly userAgentData?: NavigatorUAData; +} diff --git a/packages/@react-spectrum/ai/src/useVoiceInput.ts b/packages/@react-spectrum/ai/src/useVoiceInput.ts new file mode 100644 index 00000000000..20bb25b79b3 --- /dev/null +++ b/packages/@react-spectrum/ai/src/useVoiceInput.ts @@ -0,0 +1,292 @@ +/* + * 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 { + Dispatch, + RefObject, + SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; + +/** + * Chromium brands whose Web Speech backend is known to work. Other Chromium + * forks (Arc, Brave, Edge) expose the API but route to a backend that does not + * return results, so they are excluded. Safari has no `userAgentData` and is + * trusted directly. Add a brand here to enable another Chromium fork. + */ +const VOICE_INPUT_CHROMIUM_BROWSERS = new Set(['Google Chrome']); + +/** Normalised voice-recognition error. `null` is never surfaced (e.g. aborts). */ +export type VoiceInputErrorCode = + | 'not-allowed' + | 'network' + | 'no-speech' + | 'audio-capture' + | 'unknown'; + +// doesn't have onStart/onInterim/etc, didn't seem like they were needed since coworker just used +// it to contruct the transcript while still allowing the field be editable by the user and it was quite buggy +export interface VoiceInputProps { + lang?: string; +} + +// TODO: make a accompanying aria hook later, this is more stately like +export interface VoiceInputResult { + isSupported: boolean; + isListening: boolean; + errorCode: VoiceInputErrorCode | null; + // TODO: this returns the full voice transcript rather than offering a interim and final one like cooworker's does + // can alway offer that later + transcript: string; + start: () => void; + stop: () => void; + toggle: () => void; +} + +/** Resolves the recognizer constructor, applying the Chromium brand allowlist. */ +const getRecognitionCtor = (): SpeechRecognitionConstructor | null => { + if (typeof window === 'undefined') { + return null; + } + const ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null; + if (!ctor) { + return null; + } + const brands = navigator.userAgentData?.brands; + if (brands && !brands.some(b => VOICE_INPUT_CHROMIUM_BROWSERS.has(b.brand))) { + return null; + } + return ctor; +}; + +/** SpeechRecognition requires a secure context (HTTPS / localhost). */ +const isSecureVoiceContext = (): boolean => + typeof window !== 'undefined' && window.isSecureContext === true; + +/** Maps a raw recognition error to a normalised code, or `null` to ignore it. */ +const mapErrorCode = (error: string): VoiceInputErrorCode | null => { + switch (error) { + case 'aborted': + return null; + case 'not-allowed': + case 'service-not-allowed': + return 'not-allowed'; + case 'network': + return 'network'; + case 'no-speech': + return 'no-speech'; + case 'audio-capture': + return 'audio-capture'; + default: + return 'unknown'; + } +}; + +/** Maps a getUserMedia rejection to a normalised error code. */ +const mapMediaError = (err: unknown): VoiceInputErrorCode => { + const name = err instanceof DOMException ? err.name : ''; + if (name === 'NotAllowedError' || name === 'SecurityError') { + return 'not-allowed'; + } + if (name === 'NotFoundError' || name === 'NotReadableError') { + return 'audio-capture'; + } + return 'unknown'; +}; + +/** + * Requests microphone access so the browser permission prompt reliably opens and + * a denial is detected before recognition starts. Returns `null` on success (or + * when getUserMedia is unavailable, deferring to the recognizer's own prompt), + * or an error code on failure. The granted stream is released immediately — + * SpeechRecognition opens its own. + */ +const requestMicrophone = async (): Promise => { + const media = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined; + if (!media?.getUserMedia) { + return null; + } + try { + const stream = await media.getUserMedia({audio: true}); + stream.getTracks().forEach(track => track.stop()); + return null; + } catch (err) { + return mapMediaError(err); + } +}; + +/** Accumulates interim vs final transcripts from a recognition result event. */ +const readTranscript = (event: SpeechRecognitionEvent): {interim: string; final: string} => { + let interim = ''; + let final = ''; + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + const alternative = result?.[0]; + if (result && alternative) { + if (result.isFinal) { + final += alternative.transcript; + } else { + interim += alternative.transcript; + } + } + } + return {interim, final}; +}; + +interface RecognizerSetup { + recognitionRef: RefObject; + setListening: Dispatch>; + setError: Dispatch>; + setInterim: Dispatch>; + appendFinal: (s: string) => void; +} + +/** + * Creates and wires a SpeechRecognition instance. On error/end the ref is cleared, + * but only when this instance is still the active one, so a late event from a + * previous session can't wipe out a newly-started recognizer. + */ +const createRecognizer = ( + ctor: SpeechRecognitionConstructor, + lang: string, + setup: RecognizerSetup +): SpeechRecognition => { + const recognition = new ctor(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = lang; + + // Ignore events from a session that is no longer the active recognizer (e.g. a + // late onend/onerror after a new session started), so they can't flip the + // listening state, surface a st ale error, or clear a newer recognizer. + const isActive = () => setup.recognitionRef.current === recognition; + + recognition.onstart = () => { + setup.setListening(true); + }; + recognition.onresult = event => { + if (!isActive()) { + return; + } + const {interim, final} = readTranscript(event); + if (final) { + setup.appendFinal(final); + } + setup.setInterim(interim); + }; + recognition.onerror = event => { + if (!isActive()) { + return; + } + const code = mapErrorCode(event.error); + if (code) { + setup.setError(code); + } + setup.setListening(false); + setup.setInterim(''); + // Clear so the next press starts a fresh session instead of calling stop(). + setup.recognitionRef.current = null; + }; + recognition.onend = () => { + if (!isActive()) { + return; + } + setup.setListening(false); + setup.setInterim(''); + setup.recognitionRef.current = null; + }; + + return recognition; +}; + +export const useVoiceInput = (options: VoiceInputProps): VoiceInputResult => { + const {lang} = options; + + // Resolve support once — it cannot change for the lifetime of the page. + const ctor = useMemo(() => (isSecureVoiceContext() ? getRecognitionCtor() : null), []); + const isSupported = ctor !== null; + + const [isListening, setIsListening] = useState(false); + const [errorCode, setErrorCode] = useState(null); + const [interimTranscript, setInterimTranscript] = useState(''); + const [finalTranscript, setFinalTranscript] = useState(''); + const transcript = finalTranscript + interimTranscript; + + const recognitionRef = useRef(null); + const startingRef = useRef(false); + const mountedRef = useRef(true); + + const stop = useCallback(() => { + recognitionRef.current?.stop(); + }, []); + + const start = useCallback(() => { + // Guard against double-start (a started recognizer throws InvalidStateError) + // and against re-entry while the permission prompt is open. + if (!ctor || recognitionRef.current || startingRef.current) { + return; + } + startingRef.current = true; + setErrorCode(null); + setInterimTranscript(''); + setFinalTranscript(''); + + // Request mic access first so the browser permission prompt reliably opens + // and a denial is reported before recognition starts. + void requestMicrophone().then(micError => { + startingRef.current = false; + if (!mountedRef.current) { + return; + } + if (micError) { + setErrorCode(micError); + return; + } + // Bail if unmounted or already started while the prompt was open. + if (recognitionRef.current) { + return; + } + + const recognition = createRecognizer(ctor, lang ?? navigator.language ?? 'en-US', { + recognitionRef, + setListening: setIsListening, + setError: setErrorCode, + setInterim: setInterimTranscript, + appendFinal: s => setFinalTranscript(prev => prev + s) + }); + recognitionRef.current = recognition; + recognition.start(); + }); + }, [ctor, lang]); + + const toggle = useCallback(() => { + if (recognitionRef.current) { + stop(); + } else { + start(); + } + }, [start, stop]); + + // Abort on unmount so dangling callbacks never fire after teardown. + useEffect(() => { + return () => { + mountedRef.current = false; + recognitionRef.current?.abort(); + }; + }, []); + + return {isSupported, isListening, errorCode, transcript, start, stop, toggle}; +}; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 00868084bfd..e31a7b99112 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -23,6 +23,7 @@ import { PromptFieldAttachmentList, PromptFieldSubmitButton, PromptFieldToolbar, + PromptFieldVoiceButton, PromptToken, PromptTokenField } from '../src/PromptField'; @@ -412,7 +413,11 @@ function EverythingRender(args) { - + {/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */} +
+ + +
From 187ce00b2d6c293594a0af748adfac32c974b382 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 14 Jul 2026 14:31:34 -0700 Subject: [PATCH 10/25] add missing error messages --- packages/@react-spectrum/ai/src/useVoiceInput.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/@react-spectrum/ai/src/useVoiceInput.ts b/packages/@react-spectrum/ai/src/useVoiceInput.ts index 20bb25b79b3..cbca44ce6e1 100644 --- a/packages/@react-spectrum/ai/src/useVoiceInput.ts +++ b/packages/@react-spectrum/ai/src/useVoiceInput.ts @@ -35,6 +35,8 @@ export type VoiceInputErrorCode = | 'network' | 'no-speech' | 'audio-capture' + | 'language-not-supported' + | 'phrases-not-supported' | 'unknown'; // doesn't have onStart/onInterim/etc, didn't seem like they were needed since coworker just used @@ -90,6 +92,10 @@ const mapErrorCode = (error: string): VoiceInputErrorCode | null => { return 'no-speech'; case 'audio-capture': return 'audio-capture'; + case 'language-not-supported': + return 'language-not-supported'; + case 'phrases-not-supported': + return 'phrases-not-supported'; default: return 'unknown'; } From e09f031546ee1434555a7892fcd98203920866e5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 14 Jul 2026 15:04:23 -0700 Subject: [PATCH 11/25] removing isDisabled support from the prompt field, need design feedback --- .../@react-spectrum/ai/src/PromptField.tsx | 39 ++++--------------- .../ai/stories/PromptField.stories.tsx | 4 +- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 6be73cfd1c9..c3d626ebf03 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -86,7 +86,6 @@ export interface PromptFieldProps { onAttachmentsChange?: (attachments: PromptFieldAttachment[]) => void; onSubmit?: (prompt: TokenSegmentList, attachments: PromptFieldAttachment[]) => void; isGenerating?: boolean; - isDisabled?: boolean; onStop?: () => void; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; @@ -104,7 +103,6 @@ interface PromptFieldState { onSubmit?: () => void; onStop?: () => void; isGenerating: boolean; - isDisabled: boolean; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; isListening: boolean; @@ -164,7 +162,6 @@ const PromptFieldContext = createContext({ setPrompt: () => {}, inputRef: createRef(), isGenerating: false, - isDisabled: false, isListening: false, setListening: () => {} }); @@ -191,7 +188,6 @@ export function PromptField(props: PromptFieldProps) { children, acceptedAttachmentTypes, isGenerating, - isDisabled, onStop, styles, onAddAttachments, @@ -242,7 +238,7 @@ export function PromptField(props: PromptFieldProps) { let isPromptControlled = props.value !== undefined; let isAttachmentsControlled = props.attachments !== undefined; let onSubmit = () => { - if (prompt.segments.length === 0 || isDisabled) { + if (prompt.segments.length === 0) { return; } @@ -267,7 +263,6 @@ export function PromptField(props: PromptFieldProps) { inputRef, onSubmit, isGenerating: isGenerating ?? false, - isDisabled: isDisabled ?? false, isListening, setListening, onStop, @@ -277,7 +272,6 @@ export function PromptField(props: PromptFieldProps) {
React.ReactElement; pixelLoader?: Cell[] | Cell[][]; placeholder?: string; - isDisabled?: boolean; onKeyDown?: (e: React.KeyboardEvent) => void; } export function PromptTokenField(props: PromptTokenFieldProps) { - let { - completionTrigger, - renderCompletions, - children, - pixelLoader, - placeholder, - isDisabled: isDisabledProp, - onKeyDown - } = props; + let {completionTrigger, renderCompletions, children, pixelLoader, placeholder, onKeyDown} = props; let { prompt, setPrompt, @@ -365,10 +350,8 @@ export function PromptTokenField(props: PromptTokenFieldProps) { inputRef, onSubmit, isGenerating, - isDisabled: isDisabledContext, isListening } = useContext(PromptFieldContext); - let isDisabled = isDisabledProp || isDisabledContext; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [isFocused, setFocused] = useState(false); @@ -416,7 +399,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) { multiline aria-label="Prompt" data-placeholder={placeholder || stringFormatter.format('promptfield.placeholder')} - isDisabled={isDisabled} isReadOnly={isListening} onKeyDown={onKeyDown} ref={inputRef} @@ -591,13 +573,13 @@ export interface PromptFieldSubmitButtonProps {} // eslint-disable-next-line @typescript-eslint/no-unused-vars export function PromptFieldSubmitButton(props: PromptFieldSubmitButtonProps) { - let {prompt, isGenerating, isDisabled, onSubmit, onStop} = useContext(PromptFieldContext); + let {prompt, isGenerating, onSubmit, onStop} = useContext(PromptFieldContext); return (
@@ -301,6 +302,7 @@ export interface PromptFieldAttachmentListProps extends AttachmentListProps { let removedAttachments = attachments.filter(attachment => keys.has(attachment.id)); onRemoveAttachments?.(removedAttachments); @@ -397,7 +399,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { value={prompt} onChange={setPrompt} multiline - aria-label="Prompt" + aria-label={stringFormatter.format('promptfield.prompt')} data-placeholder={placeholder || stringFormatter.format('promptfield.placeholder')} isReadOnly={isListening} onKeyDown={onKeyDown} @@ -574,13 +576,18 @@ 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 ( @@ -698,9 +705,13 @@ export interface InsertMenuItemProps { export function InsertMenuButton(props: InsertMenuItemProps) { let {children} = props; + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); return ( - + {children} From b1aa1404904fbe8a82f584debc987fc9c1c1d292 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Jul 2026 09:52:25 -0700 Subject: [PATCH 13/25] add error variant for attachement list card --- .../@react-spectrum/ai/src/AttachmentList.tsx | 43 +++++++++++++++++-- .../@react-spectrum/ai/src/HorizontalCard.tsx | 17 ++++++-- .../ai/stories/AttachmentList.stories.tsx | 30 ++++++++----- .../ai/stories/PromptField.stories.tsx | 9 +++- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 7a9281f6bb5..e1e392f5745 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import AlertTriangle from '@react-spectrum/s2/icons/AlertTriangle'; import { AriaLabelingProps, DOMProps, @@ -189,6 +190,8 @@ export interface AttachmentProps /** The children of the Attachment. */ children: ReactNode | ((renderProps: AttachmentRenderProps) => ReactNode); uploadProgress?: number; + /** Whether the attachment has an error. */ + isInvalid?: boolean; /** * Spectrum-defined styles, returned by the `style()` macro. */ @@ -203,6 +206,17 @@ const tagStyles = style({ borderRadius: 'default' }); +const attachmentErrorStyles = style({ + display: 'flex', + flexShrink: 0, + alignItems: 'center', + paddingStart: 8, + '--iconPrimary': { + type: 'color', + value: 'negative' + } +}); + export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -214,6 +228,9 @@ export const Attachment = forwardRef(function Attachment( 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, styles, + isInvalid, + children, + size = 'M', ...otherProps } = props; let domRef = useDOMRef(ref); @@ -227,7 +244,7 @@ export const Attachment = forwardRef(function Attachment( aria-describedby={ariaDescribedby} ref={domRef} className={renderProps => mergeStyles(tagStyles({...renderProps}), styles)}> - + {props.uploadProgress != null && props.uploadProgress < 100 && (
- {typeof props.children === 'function' - ? props.children({size: otherProps.size || 'M'}) - : props.children} + {typeof children === 'function' ? children({size}) : children} )} + {isInvalid && ( +
+ +
+ )} {/** Definitely not a close button, though looks like one. */}
); }); + +function AlertTriangleIcon({size}) { + switch (size) { + case 'XS': + return ; + case 'S': + return ; + case 'M': + return ; + case 'L': + return ; + case 'XL': + return ; + } +} diff --git a/packages/@react-spectrum/ai/src/HorizontalCard.tsx b/packages/@react-spectrum/ai/src/HorizontalCard.tsx index a9f71686e8a..fe597abb6e5 100644 --- a/packages/@react-spectrum/ai/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/ai/src/HorizontalCard.tsx @@ -89,6 +89,8 @@ export interface BasicCardProps extends Omit { * @default 'primary' */ variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; + /** Whether the card is in an error state. */ + isInvalid?: boolean; } const borderRadius = { @@ -119,7 +121,11 @@ let card = style({ default: lightDark('transparent-white-300', 'transparent-black-300'), forcedColors: 'ButtonFace' }, - boxShadow: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, + // TODO: for cards that only have images, should they have a red outline around the image thumbnail? + boxShadow: { + default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, + isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` + }, forcedColorAdjust: 'none', transition: 'default', fontFamily: 'sans', @@ -422,6 +428,7 @@ const actionButtonSize = { const Card = forwardRef(function Card( props: Omit & { isBasic?: boolean; + isInvalid?: boolean; variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; }, ref: DOMRef @@ -430,6 +437,7 @@ const Card = forwardRef(function Card( let domRef = useDOMRef(ref); let { isBasic = false, + isInvalid = false, density = 'regular', size = 'M', variant = 'primary', @@ -498,6 +506,7 @@ const Card = forwardRef(function Card( density, variant, isBasic, + isInvalid, isCardView: false, isLink: true }), @@ -527,7 +536,7 @@ const Card = forwardRef(function Card( inert={inertValue(isSkeleton)} ref={domRef} className={mergeStyles( - card({size, density, variant, isBasic, isCardView: ElementType !== 'div'}), + card({size, density, variant, isBasic, isInvalid, isCardView: ElementType !== 'div'}), styles )}> = { tags: ['autodocs'], argTypes: { ...categorizeArgTypes('Events', events), - children: {table: {disable: true}} + children: {table: {disable: true}}, + isInvalid: {control: 'boolean'}, + size: { + control: 'radio', + options: ['XS', 'S', 'M', 'L', 'XL'] + } }, - args: {...getActionArgs(events)}, + args: {isInvalid: false, size: 'M', ...getActionArgs(events)}, title: 'AI/AttachmentList' }; @@ -38,28 +43,29 @@ export default meta; type Story = StoryObj; -export const AIAttachmentList: Story = { - render: args => ( - - +function AttachmentListRender(args) { + let {isInvalid, size, ...listArgs} = args; + return ( + + - + - + - + - ) + ); +} + +export const AIAttachmentList: Story = { + render: args => }; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 268060b7d3d..ab517cf577b 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -82,6 +82,10 @@ const meta: Meta = { control: 'radio', options: ['thumbnail', 'card'] }, + attachmentInvalid: { + control: 'boolean', + description: 'Sets attachments to an invalid state.' + }, placeholder: { control: 'text', table: {category: 'PromptTokenField'} @@ -91,6 +95,7 @@ const meta: Meta = { brand: 'rgb(236, 105, 255)', pixelLoader: 'aiLogo', attachmentVariant: 'thumbnail', + attachmentInvalid: false, placeholder: undefined, ...getActionArgs(events) }, @@ -333,11 +338,13 @@ function EverythingRender(args) { return newState; }); }}> - + {attachment => { let state = attachmentState.get(attachment.id); return ( {/* TODO: what about non-image attachments? */} {attachment.image && } From e89bed820090bfa0d635cbd8a633de7c1c38c266 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Jul 2026 10:51:34 -0700 Subject: [PATCH 14/25] fix lint --- packages/@react-spectrum/ai/package.json | 1 + .../@react-spectrum/ai/src/AttachmentList.tsx | 6 +- .../@react-spectrum/ai/src/PromptField.tsx | 8 +-- .../@react-spectrum/ai/src/ResponseStatus.tsx | 2 +- .../ai/src/speech-recognition.d.ts | 2 +- .../ai/stories/Chat.stories.tsx | 66 +++++++++---------- .../ai/stories/PromptField.stories.tsx | 4 +- yarn.lock | 1 + 8 files changed, 46 insertions(+), 44 deletions(-) diff --git a/packages/@react-spectrum/ai/package.json b/packages/@react-spectrum/ai/package.json index 4d32a79c534..fb09f74a77d 100644 --- a/packages/@react-spectrum/ai/package.json +++ b/packages/@react-spectrum/ai/package.json @@ -47,6 +47,7 @@ "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", + "@react-aria/utils": "^3.8.0", "@react-spectrum/s2": "1.5.1", "@react-types/shared": "^3.36.0", "react-aria": "3.50.0", diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index e1e392f5745..bc5a3e587cb 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -24,11 +24,10 @@ import {Button} from 'react-aria-components/Button'; import {CardProps} from '@react-spectrum/s2/Card'; import Close from '@react-spectrum/s2/icons/Close'; import {forwardRef, ReactNode, useRef} from 'react'; -// @ts-ignore -import intlMessages from '../intl/*.json'; -import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; import {ImageContext} from '@react-spectrum/s2/Image'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; @@ -42,6 +41,7 @@ import { TagProps } from 'react-aria-components/TagGroup'; import {useDOMRef} from './useDOMRef'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; interface AttachmentRenderProps { /** The size of the Card. */ diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 53f8e62fc85..df92e1327e0 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -11,10 +11,7 @@ */ import {ActionButton} from '@react-spectrum/s2/ActionButton'; -import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; -import {Tooltip, TooltipTrigger} from '@react-spectrum/s2/Tooltip'; import Attach from '@react-spectrum/s2/icons/Attach'; -import Microphone from '@react-spectrum/s2/icons/Microphone'; import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList'; import {Autocomplete} from 'react-aria-components/Autocomplete'; import {Button} from '@react-spectrum/s2/Button'; @@ -53,6 +50,7 @@ import intlMessages from '../intl/*.json'; 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'; @@ -61,10 +59,12 @@ import {PromptFieldContainer} from './PromptFieldContainer'; import {PromptFocusContext} from './Chat'; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import Stop from '@react-spectrum/s2/icons/StopProcessing'; +import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; +import {Tooltip, TooltipTrigger} from '@react-spectrum/s2/Tooltip'; import {useControlledState} from 'react-stately/useControlledState'; -import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusWithin} from 'react-aria/useFocusWithin'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; export interface PromptFieldAttachment { diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 172c4ea0bf5..abf07ac1369 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -45,11 +45,11 @@ import React, { ReactNode, useCallback, useContext, - useLayoutEffect, useState } from 'react'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; +import {useLayoutEffect} from '@react-aria/utils'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; diff --git a/packages/@react-spectrum/ai/src/speech-recognition.d.ts b/packages/@react-spectrum/ai/src/speech-recognition.d.ts index b2a727eb134..aa58bcee34a 100644 --- a/packages/@react-spectrum/ai/src/speech-recognition.d.ts +++ b/packages/@react-spectrum/ai/src/speech-recognition.d.ts @@ -41,7 +41,7 @@ interface SpeechRecognition extends EventTarget { } interface SpeechRecognitionConstructor { - new(): SpeechRecognition; + new (): SpeechRecognition; } interface Window { diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index c0e5d7488cb..7427c2f990d 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -10,16 +10,10 @@ * governing permissions and limitations under the License. */ +import {action} from 'storybook/actions'; import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; -import {Chat} from '../src/Chat'; -import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; -import {Content} from '@react-spectrum/s2/Content'; -import {GridList} from 'react-aria-components'; -import {Image} from '@react-spectrum/s2/Image'; -import {ListLayout} from 'react-stately/useVirtualizerState'; -import {MenuItem} from '@react-spectrum/s2/Menu'; import { AutoLinkingSegmentList, MessageFeedback, @@ -40,7 +34,13 @@ import { TokenSegmentList, UserMessage } from '@react-spectrum/ai'; -import {action} from 'storybook/actions'; +import {Chat} from '../src/Chat'; +import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; +import {Content} from '@react-spectrum/s2/Content'; +import {GridList} from 'react-aria-components'; +import {Image} from '@react-spectrum/s2/Image'; +import {ListLayout} from 'react-stately/useVirtualizerState'; +import {MenuItem} from '@react-spectrum/s2/Menu'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactNode, useEffect, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -222,29 +222,6 @@ function StreamingChatRender() { let [promptValue, setPromptValue] = useState(new AutoLinkingSegmentList([])); let followUpMessage = useRef(null); - useEffect(() => { - if (!isGenerating && followUpMessage.current) { - let followup = followUpMessage.current; - followUpMessage.current = null; - handleSend(followup); - } - }, [isGenerating]); - - // TODO: maybe also have it finalize any in progress tool calls and what not, but do it later - function handleStop() { - followUpMessage.current = null; - timeouts.current.forEach(clearTimeout); - timeouts.current = []; - setMessages(prev => - prev.map(m => - (m.type === 'system' || m.type === 'status') && m.isStreaming - ? {...m, isStreaming: false} - : m - ) - ); - setGenerating(false); - } - function handleSend(prompt: TokenSegmentList) { setGenerating(true); // user message added first so its announcement plays before @@ -440,6 +417,29 @@ function StreamingChatRender() { }, streamEndTimestamp + 1000); } + useEffect(() => { + if (!isGenerating && followUpMessage.current) { + let followup = followUpMessage.current; + followUpMessage.current = null; + handleSend(followup); + } + }, [isGenerating]); + + // TODO: maybe also have it finalize any in progress tool calls and what not, but do it later + function handleStop() { + followUpMessage.current = null; + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + setMessages(prev => + prev.map(m => + (m.type === 'system' || m.type === 'status') && m.isStreaming + ? {...m, isStreaming: false} + : m + ) + ); + setGenerating(false); + } + return ( // TODO: these extra div wrappers would need to be implemented by the RAC user, maybe we can internalize some more? // of particular note is the scroll button. Same for the other styles @@ -521,7 +521,7 @@ function StreamingChatRender() { textValue={announcement} isStreaming={msg.isStreaming} shouldAnnounceOnMount> - + {title} {msg.details && ( @@ -686,7 +686,7 @@ export function VirtualizedChat() { return ( - + {message} diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index ab517cf577b..38b3c221fa4 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -11,6 +11,7 @@ */ import {action} from 'storybook/actions'; +import {ActionButton} from '@react-spectrum/s2/ActionButton'; import { AttachFileMenuItem, AutoLinkingSegmentList, @@ -27,8 +28,6 @@ import { PromptToken, PromptTokenField } from '../src/PromptField'; -import {TokenSegmentList} from '../src/TokenSegmentList'; -import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {Attachment} from '../src/AttachmentList'; import Brand from '@react-spectrum/s2/icons/Brand'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; @@ -52,6 +51,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; +import {TokenSegmentList} from '../src/TokenSegmentList'; import UserGroup from '@react-spectrum/s2/icons/UserGroup'; import {useState} from 'react'; diff --git a/yarn.lock b/yarn.lock index e8dbe2e04b4..73538527b02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7377,6 +7377,7 @@ __metadata: "@internationalized/date": "npm:^3.12.2" "@internationalized/number": "npm:^3.6.7" "@react-aria/test-utils": "npm:^1.0.0-alpha.8" + "@react-aria/utils": "npm:^3.8.0" "@react-spectrum/s2": "npm:1.5.1" "@react-types/shared": "npm:^3.36.0" "@storybook/jest": "npm:^0.2.3" From d64cff69f25647c82486902d82ce1da312d5d0c4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Jul 2026 11:08:20 -0700 Subject: [PATCH 15/25] make up/down arrow cycle through old prompts --- .../ai/stories/PromptField.stories.tsx | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 38b3c221fa4..986fb7d8a2c 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -52,8 +52,8 @@ import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; import {TokenSegmentList} from '../src/TokenSegmentList'; +import {useRef, useState} from 'react'; import UserGroup from '@react-spectrum/s2/icons/UserGroup'; -import {useState} from 'react'; const events = ['onSubmit', 'onStop', 'onAddAttachments', 'onRemoveAttachments']; @@ -277,6 +277,9 @@ function EverythingRender(args) { let [value, setValue] = useState(() => new AutoLinkingSegmentList([])); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); + let historyRef = useRef([]); + let historyIndexRef = useRef(-1); + let isHistoryNavigating = useRef(false); let mockUpload = async (id: string) => { await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); @@ -297,6 +300,55 @@ function EverythingRender(args) { }); }; + let isFieldEmpty = (prompt: TokenSegmentList) => { + let text = prompt.toString(); + return text === '' && !text.includes('\n'); + }; + + // logic for using up/down arrow keys to fill field with previous prompts + let onKeyDown = (e: React.KeyboardEvent) => { + let canNavigate = historyIndexRef.current !== -1 || isFieldEmpty(value); + let history = historyRef.current; + if (!canNavigate || history.length === 0) { + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + let nextIndex = + historyIndexRef.current === -1 + ? history.length - 1 + : Math.max(0, historyIndexRef.current - 1); + historyIndexRef.current = nextIndex; + isHistoryNavigating.current = true; + setValue(history[nextIndex]); + } else if (e.key === 'ArrowDown') { + if (historyIndexRef.current === -1) { + return; + } + e.preventDefault(); + let nextIndex = historyIndexRef.current + 1; + if (nextIndex >= history.length) { + historyIndexRef.current = -1; + isHistoryNavigating.current = true; + setValue(new AutoLinkingSegmentList([])); + } else { + historyIndexRef.current = nextIndex; + isHistoryNavigating.current = true; + setValue(history[nextIndex]); + } + } + }; + + let handleChange = (newValue: TokenSegmentList) => { + if (!isHistoryNavigating.current) { + // if user edits the field, then we want to reset the index so up arrow starts from latest prompt again + historyIndexRef.current = -1; + } + isHistoryNavigating.current = false; + setValue(newValue); + }; + return (
@@ -309,11 +361,13 @@ function EverythingRender(args) { { action('onSubmit')(prompt.toString()); + historyRef.current = [...historyRef.current, prompt]; + historyIndexRef.current = -1; setValue(new AutoLinkingSegmentList([])); setAttachments([]); setAttachmentState(new Map()); @@ -370,7 +424,8 @@ function EverythingRender(args) { }) } pixelLoader={data[args.pixelLoader]} - placeholder={placeholder}> + placeholder={placeholder} + onKeyDown={onKeyDown}> {segment => ( {icons[segment.value?.type]} From f9f020afc0f963bc7e02e7192a81ca2c04796739 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Jul 2026 11:50:44 -0700 Subject: [PATCH 16/25] fix lint? --- packages/@react-aria/test-utils/src/utils.ts | 2 +- packages/react-aria/src/utils/platform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/test-utils/src/utils.ts b/packages/@react-aria/test-utils/src/utils.ts index d9d6e0e6747..07b26b881ea 100644 --- a/packages/@react-aria/test-utils/src/utils.ts +++ b/packages/@react-aria/test-utils/src/utils.ts @@ -17,7 +17,7 @@ import {UserOpts} from './types'; export const DEFAULT_LONG_PRESS_TIME = 500; function testPlatform(re: RegExp) { return typeof window !== 'undefined' && window.navigator != null - ? re.test(window.navigator['userAgentData']?.platform || window.navigator.platform) + ? re.test((window.navigator['userAgentData'] as any)?.platform || window.navigator.platform) : false; } diff --git a/packages/react-aria/src/utils/platform.ts b/packages/react-aria/src/utils/platform.ts index f0d91b0824f..db290c51aa8 100644 --- a/packages/react-aria/src/utils/platform.ts +++ b/packages/react-aria/src/utils/platform.ts @@ -24,7 +24,7 @@ function testUserAgent(re: RegExp) { function testPlatform(re: RegExp) { return typeof window !== 'undefined' && window.navigator != null - ? re.test(window.navigator['userAgentData']?.platform || window.navigator.platform) + ? re.test((window.navigator['userAgentData'] as any)?.platform || window.navigator.platform) : false; } From 54e6e40bdd5c7ff2107053c1c03a280df6685241 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Jul 2026 11:51:59 -0700 Subject: [PATCH 17/25] missing events from spec --- .../ai/src/speech-recognition.d.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/ai/src/speech-recognition.d.ts b/packages/@react-spectrum/ai/src/speech-recognition.d.ts index aa58bcee34a..4d38027e7e7 100644 --- a/packages/@react-spectrum/ai/src/speech-recognition.d.ts +++ b/packages/@react-spectrum/ai/src/speech-recognition.d.ts @@ -15,6 +15,17 @@ // types, and the User-Agent Client Hints surface used for the Chromium browser // allowlist. SpeechRecognitionAlternative, SpeechRecognitionResult, and // SpeechRecognitionResultList ARE provided by lib.dom and are not redeclared. +// Spec: https://wicg.github.io/speech-api/ + +type SpeechRecognitionErrorCode = + | 'no-speech' + | 'aborted' + | 'audio-capture' + | 'network' + | 'not-allowed' + | 'service-not-allowed' + | 'language-not-supported' + | 'phrases-not-supported'; interface SpeechRecognitionEvent extends Event { readonly resultIndex: number; @@ -22,7 +33,7 @@ interface SpeechRecognitionEvent extends Event { } interface SpeechRecognitionErrorEvent extends Event { - readonly error: string; + readonly error: SpeechRecognitionErrorCode; readonly message: string; } @@ -34,10 +45,17 @@ interface SpeechRecognition extends EventTarget { start(): void; stop(): void; abort(): void; - onstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onend: ((this: SpeechRecognition, ev: Event) => void) | null; + onaudiostart: ((this: SpeechRecognition, ev: Event) => void) | null; + onsoundstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onspeechstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onspeechend: ((this: SpeechRecognition, ev: Event) => void) | null; + onsoundend: ((this: SpeechRecognition, ev: Event) => void) | null; + onaudioend: ((this: SpeechRecognition, ev: Event) => void) | null; onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; + onnomatch: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; } interface SpeechRecognitionConstructor { From 1557b0a4c94e6ea0dd9678648a79be5eacf3c41e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 16 Jul 2026 11:54:13 -0700 Subject: [PATCH 18/25] proper fix for NavigatorUAData lint issue --- packages/@react-aria/test-utils/src/utils.ts | 2 +- packages/@react-spectrum/ai/src/speech-recognition.d.ts | 1 + packages/react-aria/src/utils/platform.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/test-utils/src/utils.ts b/packages/@react-aria/test-utils/src/utils.ts index 07b26b881ea..d9d6e0e6747 100644 --- a/packages/@react-aria/test-utils/src/utils.ts +++ b/packages/@react-aria/test-utils/src/utils.ts @@ -17,7 +17,7 @@ import {UserOpts} from './types'; export const DEFAULT_LONG_PRESS_TIME = 500; function testPlatform(re: RegExp) { return typeof window !== 'undefined' && window.navigator != null - ? re.test((window.navigator['userAgentData'] as any)?.platform || window.navigator.platform) + ? re.test(window.navigator['userAgentData']?.platform || window.navigator.platform) : false; } diff --git a/packages/@react-spectrum/ai/src/speech-recognition.d.ts b/packages/@react-spectrum/ai/src/speech-recognition.d.ts index 4d38027e7e7..9f4c79cc463 100644 --- a/packages/@react-spectrum/ai/src/speech-recognition.d.ts +++ b/packages/@react-spectrum/ai/src/speech-recognition.d.ts @@ -75,6 +75,7 @@ interface NavigatorUABrandVersion { interface NavigatorUAData { readonly brands: ReadonlyArray; + readonly platform?: string; } interface Navigator { diff --git a/packages/react-aria/src/utils/platform.ts b/packages/react-aria/src/utils/platform.ts index db290c51aa8..f0d91b0824f 100644 --- a/packages/react-aria/src/utils/platform.ts +++ b/packages/react-aria/src/utils/platform.ts @@ -24,7 +24,7 @@ function testUserAgent(re: RegExp) { function testPlatform(re: RegExp) { return typeof window !== 'undefined' && window.navigator != null - ? re.test((window.navigator['userAgentData'] as any)?.platform || window.navigator.platform) + ? re.test(window.navigator['userAgentData']?.platform || window.navigator.platform) : false; } From e50f872521bd81c3600b0e5aa46c66c3cbd656fe Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 17 Jul 2026 14:18:19 -0700 Subject: [PATCH 19/25] review comments update to Attachement/horizontal card to properly set aria-invalid and center the progress circle in the thumbnail. Also updates to PromptField to use useKeyboard, and voice input transcript state updates --- packages/@react-spectrum/ai/intl/en-US.json | 2 +- .../@react-spectrum/ai/src/AttachmentList.tsx | 7 ++-- .../@react-spectrum/ai/src/HorizontalCard.tsx | 2 ++ .../@react-spectrum/ai/src/PromptField.tsx | 35 +++++++++++-------- .../@react-spectrum/ai/src/useVoiceInput.ts | 27 ++++++++++---- .../ai/stories/AttachmentList.stories.tsx | 27 +++++++++++--- .../src/selection/useSelectableCollection.ts | 2 +- 7 files changed, 71 insertions(+), 31 deletions(-) diff --git a/packages/@react-spectrum/ai/intl/en-US.json b/packages/@react-spectrum/ai/intl/en-US.json index 95510f260b8..b6ceb9e6e21 100644 --- a/packages/@react-spectrum/ai/intl/en-US.json +++ b/packages/@react-spectrum/ai/intl/en-US.json @@ -9,7 +9,7 @@ "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", "promptfield.aiUserGuidlines": "AI User Guidelines", - "promptfield.attachements": "Attachements", + "promptfield.attachments": "Attachments", "promptfield.prompt": "Prompt", "promptfield.stopButton": "Stop", "promptfield.submitButton": "Send", diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index bc5a3e587cb..2856c8cfe15 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -252,13 +252,16 @@ export const Attachment = forwardRef(function Attachment( top: '50%', insetStart: { default: '50%', - ':has(~ [data-slot=content])': 32 + ':has(~ [data-slot=content])': + '[calc(var(--card-padding-x) + var(--basic-thumb-size) / 2)]' }, transform: 'translate(-50%, -50%)' })}>
@@ -287,7 +290,7 @@ export const Attachment = forwardRef(function Attachment( )} {isInvalid && ( -
+ )} diff --git a/packages/@react-spectrum/ai/src/HorizontalCard.tsx b/packages/@react-spectrum/ai/src/HorizontalCard.tsx index fe597abb6e5..ca2f1164f30 100644 --- a/packages/@react-spectrum/ai/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/ai/src/HorizontalCard.tsx @@ -532,6 +532,7 @@ const Card = forwardRef(function Card(
mergeStyles( card({ diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index df92e1327e0..1ecefeec3ad 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -64,6 +64,7 @@ import {Tooltip, TooltipTrigger} from '@react-spectrum/s2/Tooltip'; import {useControlledState} from 'react-stately/useControlledState'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusWithin} from 'react-aria/useFocusWithin'; +import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; @@ -310,7 +311,7 @@ export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) return ( { let removedAttachments = attachments.filter(attachment => keys.has(attachment.id)); onRemoveAttachments?.(removedAttachments); @@ -342,7 +343,15 @@ export interface PromptTokenFieldProps { } export function PromptTokenField(props: PromptTokenFieldProps) { - let {completionTrigger, renderCompletions, children, pixelLoader, placeholder, onKeyDown} = props; + let { + completionTrigger, + renderCompletions, + children, + pixelLoader, + placeholder, + onKeyDown: onKeyDownProp + } = props; + let {keyboardProps} = useKeyboard({onKeyDown: onKeyDownProp}); let { prompt, setPrompt, @@ -396,13 +405,13 @@ export function PromptTokenField(props: PromptTokenFieldProps) { { if (e.isTrusted) { @@ -615,11 +624,10 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { let { isSupported, isListening: isVoiceListening, - errorCode, transcript, toggle, stop - } = useVoiceInput({lang}); + } = useVoiceInput({lang, onError, onListeningChange: setListening}); let restoreFocus = useEffectEvent(() => { if (!inputRef.current) { @@ -642,15 +650,18 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { wasListeningRef.current = false; restoreFocus(); } - setListening(isVoiceListening); - }, [isVoiceListening, setListening]); + }, [isVoiceListening]); - useEffect(() => { + let applyVoiceTranscript = useEffectEvent(() => { if (!transcript || !isVoiceListening) { return; } + setPrompt(buildVoicePrompt(basePromptRef.current, transcript)); - }, [transcript, isVoiceListening, setPrompt]); + }); + useEffect(() => { + applyVoiceTranscript(); + }, [transcript, isVoiceListening]); // TODO this is from coworker, is to stop mutating the input text since the button becomes disabled when the chat // is streaming, keep it? @@ -660,12 +671,6 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { } }, [isDisabled, isVoiceListening, stop]); - useEffect(() => { - if (errorCode) { - onError?.(errorCode); - } - }, [errorCode, onError]); - if (!isSupported) { return null; } diff --git a/packages/@react-spectrum/ai/src/useVoiceInput.ts b/packages/@react-spectrum/ai/src/useVoiceInput.ts index cbca44ce6e1..0f655bef156 100644 --- a/packages/@react-spectrum/ai/src/useVoiceInput.ts +++ b/packages/@react-spectrum/ai/src/useVoiceInput.ts @@ -20,6 +20,7 @@ import { useRef, useState } from 'react'; +import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; /** * Chromium brands whose Web Speech backend is known to work. Other Chromium @@ -43,6 +44,8 @@ export type VoiceInputErrorCode = // it to contruct the transcript while still allowing the field be editable by the user and it was quite buggy export interface VoiceInputProps { lang?: string; + onError?: (code: VoiceInputErrorCode) => void; + onListeningChange?: (isListening: boolean) => void; } // TODO: make a accompanying aria hook later, this is more stately like @@ -155,6 +158,8 @@ const readTranscript = (event: SpeechRecognitionEvent): {interim: string; final: interface RecognizerSetup { recognitionRef: RefObject; setListening: Dispatch>; + onListeningChange: (isListening: boolean) => void; + onError: (code: VoiceInputErrorCode) => void; setError: Dispatch>; setInterim: Dispatch>; appendFinal: (s: string) => void; @@ -182,6 +187,7 @@ const createRecognizer = ( recognition.onstart = () => { setup.setListening(true); + setup.onListeningChange(true); }; recognition.onresult = event => { if (!isActive()) { @@ -200,8 +206,10 @@ const createRecognizer = ( const code = mapErrorCode(event.error); if (code) { setup.setError(code); + setup.onError(code); } setup.setListening(false); + setup.onListeningChange(false); setup.setInterim(''); // Clear so the next press starts a fresh session instead of calling stop(). setup.recognitionRef.current = null; @@ -211,6 +219,7 @@ const createRecognizer = ( return; } setup.setListening(false); + setup.onListeningChange(false); setup.setInterim(''); setup.recognitionRef.current = null; }; @@ -219,7 +228,7 @@ const createRecognizer = ( }; export const useVoiceInput = (options: VoiceInputProps): VoiceInputResult => { - const {lang} = options; + const {lang, onError, onListeningChange} = options; // Resolve support once — it cannot change for the lifetime of the page. const ctor = useMemo(() => (isSecureVoiceContext() ? getRecognitionCtor() : null), []); @@ -233,7 +242,12 @@ export const useVoiceInput = (options: VoiceInputProps): VoiceInputResult => { const recognitionRef = useRef(null); const startingRef = useRef(false); - const mountedRef = useRef(true); + const handleError = useEffectEvent((code: VoiceInputErrorCode) => { + onError?.(code); + }); + const handleListeningChange = useEffectEvent((v: boolean) => { + onListeningChange?.(v); + }); const stop = useCallback(() => { recognitionRef.current?.stop(); @@ -254,14 +268,12 @@ export const useVoiceInput = (options: VoiceInputProps): VoiceInputResult => { // and a denial is reported before recognition starts. void requestMicrophone().then(micError => { startingRef.current = false; - if (!mountedRef.current) { - return; - } if (micError) { setErrorCode(micError); + handleError(micError); return; } - // Bail if unmounted or already started while the prompt was open. + // Bail if already started while the permission prompt was open. if (recognitionRef.current) { return; } @@ -269,6 +281,8 @@ export const useVoiceInput = (options: VoiceInputProps): VoiceInputResult => { const recognition = createRecognizer(ctor, lang ?? navigator.language ?? 'en-US', { recognitionRef, setListening: setIsListening, + onListeningChange: handleListeningChange, + onError: handleError, setError: setErrorCode, setInterim: setInterimTranscript, appendFinal: s => setFinalTranscript(prev => prev + s) @@ -289,7 +303,6 @@ export const useVoiceInput = (options: VoiceInputProps): VoiceInputResult => { // Abort on unmount so dangling callbacks never fire after teardown. useEffect(() => { return () => { - mountedRef.current = false; recognitionRef.current?.abort(); }; }, []); diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index a659dc2fb8e..cae286fa369 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -30,6 +30,7 @@ const meta: Meta = { ...categorizeArgTypes('Events', events), children: {table: {disable: true}}, isInvalid: {control: 'boolean'}, + uploadProgress: {control: 'number', min: 0, max: 100}, size: { control: 'radio', options: ['XS', 'S', 'M', 'L', 'XL'] @@ -44,28 +45,44 @@ export default meta; type Story = StoryObj; function AttachmentListRender(args) { - let {isInvalid, size, ...listArgs} = args; + let {isInvalid, size, uploadProgress, ...listArgs} = args; return ( - + - + - + - + Date: Fri, 17 Jul 2026 14:49:52 -0700 Subject: [PATCH 20/25] more review comments --- packages/@react-spectrum/ai/exports/index.ts | 2 +- packages/@react-spectrum/ai/src/PromptField.tsx | 12 +++++++----- packages/@react-spectrum/ai/src/useVoiceInput.ts | 3 ++- .../ai/stories/PromptField.stories.tsx | 13 +++++-------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index b2aa9e5b317..1140181e6aa 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -8,7 +8,7 @@ export { PromptFieldSubmitButton, PromptTokenField, AttachFileMenuItem, - InsertCallbackMenuItem, + CommandMenuItem, InsertMenuButton, InsertTextMenuItem, InsertTokenMenuItem, diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 1ecefeec3ad..7474610bfa7 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -682,6 +682,7 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { return ( []); return ( { recognitionRef.current = recognition; recognition.start(); }); - }, [ctor, lang]); + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [ctor, lang, handleError, handleListeningChange]); const toggle = useCallback(() => { if (recognitionRef.current) { diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 986fb7d8a2c..a677804a2b9 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -15,7 +15,7 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import { AttachFileMenuItem, AutoLinkingSegmentList, - InsertCallbackMenuItem, + CommandMenuItem, InsertMenuButton, InsertTextMenuItem, InsertTokenMenuItem, @@ -193,14 +193,11 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) {item.description} ) : item.command === '/compact' ? ( - + {item.command} {item.description} - + ) : item.command === '/feedback' || item.command === '/btw' ? ( // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing @@ -456,7 +453,7 @@ function EverythingRender(args) { ) : item.command === '/compact' ? ( // TODO: technically can be a standard menu item since triggering this action // from the + menu means no partial text to clear, but maybe we can just standardize - // InsertCallbackMenuItem as the "basic" menu item for prompt field + // CommandMenuItem as the "basic" menu item for prompt field {item.command} {item.description} @@ -511,7 +508,7 @@ function EverythingRender(args) { {/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */} -
+
From 8fe9fedffd02ddc6b2463a21c8f34d49ba90f2ff Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 20 Jul 2026 10:38:42 -0700 Subject: [PATCH 21/25] remove bad tokenization from constructor and make comment clearer --- .../@react-spectrum/ai/src/PromptField.tsx | 27 +++++-------------- .../ai/stories/PromptField.stories.tsx | 16 ++++++----- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 7474610bfa7..1e9cf25d6aa 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -40,8 +40,7 @@ import { Position, TokenFieldSegment, TokenSegment, - TokenSegmentList, - TokenSegmentListOptions + TokenSegmentList } from './TokenSegmentList'; import {IconContext} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; @@ -138,19 +137,6 @@ function tokenizeURLs(text: string): TokenFieldSegment[] { } export class AutoLinkingSegmentList extends TokenSegmentList { - // attempt to convert any text to url tokens if any - constructor(tokens: readonly TokenFieldSegment[], options?: TokenSegmentListOptions) { - let processedTokens: TokenFieldSegment[] = []; - for (let seg of tokens) { - if (seg.type === 'text') { - processedTokens.push(...tokenizeURLs(seg.text)); - } else { - processedTokens.push(seg); - } - } - super(processedTokens, options); - } - tokenize(text: string): TokenFieldSegment[] { return tokenizeURLs(text); } @@ -785,12 +771,13 @@ function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[ 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 setCursor(inputRef.current, position); - // TODO: double check this, claude debugged this one, but essentially reproduced with plain text insertion commands - // triggered one after another - // focus() fires a synchronous selectionchange before setCursor can set - // isProgrammaticSelectionChange, which resets caretPosition to {0,0} in - // TokenField's useSelectionChange handler. Re-assert the correct 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); diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index a677804a2b9..41253cd64c8 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -247,6 +247,15 @@ interface UploadState { progress?: number; } +let prompt3Base = new AutoLinkingSegmentList([ + {type: 'text', text: 'Summarize the '}, + {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}} +]); +let prompt3End = { + index: 1, + offset: prompt3Base.segments[1].text.length +}; + let prompts = [ new AutoLinkingSegmentList([ {type: 'text', text: 'Analyze '}, @@ -261,12 +270,7 @@ let prompts = [ value: {type: 'campaign', title: 'Spring Launch 2026'} } ]), - new AutoLinkingSegmentList([ - {type: 'text', text: 'Summarize the '}, - {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}}, - // TODO: note this needs a space after test.com for it to be autotokenized - {type: 'text', text: ' journey performance from test.com '} - ]) + prompt3Base.replaceRange(prompt3End, prompt3End, ' journey performance from test.com ') ]; function EverythingRender(args) { From 0837a4500da01b8535780e30b6a464e769e48437 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 20 Jul 2026 16:48:15 -0700 Subject: [PATCH 22/25] use english fallback for AI strings for now --- packages/@react-spectrum/ai/intl/ar-AE.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/bg-BG.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/cs-CZ.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/da-DK.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/de-DE.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/el-GR.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/es-ES.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/et-EE.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/fi-FI.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/fr-FR.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/he-IL.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/hr-HR.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/hu-HU.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/it-IT.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/ja-JP.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/ko-KR.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/lt-LT.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/lv-LV.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/nb-NO.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/nl-NL.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/pl-PL.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/pt-BR.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/pt-PT.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/ro-RO.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/ru-RU.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/sk-SK.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/sl-SI.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/sr-SP.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/sv-SE.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/tr-TR.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/uk-UA.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/zh-CN.json | 14 +++++++++++++- packages/@react-spectrum/ai/intl/zh-TW.json | 14 +++++++++++++- .../ai/stories/PromptField.stories.tsx | 10 +++++----- 34 files changed, 434 insertions(+), 38 deletions(-) diff --git a/packages/@react-spectrum/ai/intl/ar-AE.json b/packages/@react-spectrum/ai/intl/ar-AE.json index 53c8bab8068..b2fca80eaa6 100644 --- a/packages/@react-spectrum/ai/intl/ar-AE.json +++ b/packages/@react-spectrum/ai/intl/ar-AE.json @@ -2,5 +2,17 @@ "chat.newMessage": "رسالة جديدة", "messagefeedback.thumbDown": "استجابة سيئة", "messagefeedback.thumbUp": "استجابة جيدة", - "responsestatus.loading": "جارٍ التحميل" + "responsestatus.loading": "جارٍ التحميل", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/bg-BG.json b/packages/@react-spectrum/ai/intl/bg-BG.json index 063adbb66b2..d2f54a78bbd 100644 --- a/packages/@react-spectrum/ai/intl/bg-BG.json +++ b/packages/@react-spectrum/ai/intl/bg-BG.json @@ -2,5 +2,17 @@ "chat.newMessage": "Ново съобщение", "messagefeedback.thumbDown": "Лош отговор", "messagefeedback.thumbUp": "Добър отговор", - "responsestatus.loading": "Зареждане" + "responsestatus.loading": "Зареждане", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/cs-CZ.json b/packages/@react-spectrum/ai/intl/cs-CZ.json index 29d937d46b9..14bdfe9af42 100644 --- a/packages/@react-spectrum/ai/intl/cs-CZ.json +++ b/packages/@react-spectrum/ai/intl/cs-CZ.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nová zpráva", "messagefeedback.thumbDown": "Špatná odpověď", "messagefeedback.thumbUp": "Dobrá odpověď", - "responsestatus.loading": "Načítání" + "responsestatus.loading": "Načítání", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/da-DK.json b/packages/@react-spectrum/ai/intl/da-DK.json index f95ac272ee3..89da2f0990a 100644 --- a/packages/@react-spectrum/ai/intl/da-DK.json +++ b/packages/@react-spectrum/ai/intl/da-DK.json @@ -2,5 +2,17 @@ "chat.newMessage": "Ny besked", "messagefeedback.thumbDown": "Dårligt svar", "messagefeedback.thumbUp": "Godt svar", - "responsestatus.loading": "Indlæser" + "responsestatus.loading": "Indlæser", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/de-DE.json b/packages/@react-spectrum/ai/intl/de-DE.json index f91861a66dc..d32b815499e 100644 --- a/packages/@react-spectrum/ai/intl/de-DE.json +++ b/packages/@react-spectrum/ai/intl/de-DE.json @@ -2,5 +2,17 @@ "chat.newMessage": "Neue Nachricht", "messagefeedback.thumbDown": "Fehlerhafte Antwort", "messagefeedback.thumbUp": "Gute Antwort", - "responsestatus.loading": "Ladevorgang läuft" + "responsestatus.loading": "Ladevorgang läuft", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/el-GR.json b/packages/@react-spectrum/ai/intl/el-GR.json index 1cd35614f1e..c579f8d8fd1 100644 --- a/packages/@react-spectrum/ai/intl/el-GR.json +++ b/packages/@react-spectrum/ai/intl/el-GR.json @@ -2,5 +2,17 @@ "chat.newMessage": "Νέο μήνυμα", "messagefeedback.thumbDown": "Κακή απάντηση", "messagefeedback.thumbUp": "Καλή απάντηση", - "responsestatus.loading": "Φόρτωση" + "responsestatus.loading": "Φόρτωση", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/es-ES.json b/packages/@react-spectrum/ai/intl/es-ES.json index ec3f94e8b81..d0ed65dbbce 100644 --- a/packages/@react-spectrum/ai/intl/es-ES.json +++ b/packages/@react-spectrum/ai/intl/es-ES.json @@ -2,5 +2,17 @@ "chat.newMessage": "Mensaje nuevo", "messagefeedback.thumbDown": "Respuesta incorrecta", "messagefeedback.thumbUp": "Respuesta correcta", - "responsestatus.loading": "Cargando" + "responsestatus.loading": "Cargando", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/et-EE.json b/packages/@react-spectrum/ai/intl/et-EE.json index 714df3d9364..3742fa0aed1 100644 --- a/packages/@react-spectrum/ai/intl/et-EE.json +++ b/packages/@react-spectrum/ai/intl/et-EE.json @@ -2,5 +2,17 @@ "chat.newMessage": "Uus sõnum", "messagefeedback.thumbDown": "Halb vastus", "messagefeedback.thumbUp": "Hea vastus", - "responsestatus.loading": "Laadimine" + "responsestatus.loading": "Laadimine", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/fi-FI.json b/packages/@react-spectrum/ai/intl/fi-FI.json index 5f21d9f4628..322bbf6365e 100644 --- a/packages/@react-spectrum/ai/intl/fi-FI.json +++ b/packages/@react-spectrum/ai/intl/fi-FI.json @@ -2,5 +2,17 @@ "chat.newMessage": "Uusi viesti", "messagefeedback.thumbDown": "Huono vastaus", "messagefeedback.thumbUp": "Hyvä vastaus", - "responsestatus.loading": "Ladataan" + "responsestatus.loading": "Ladataan", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/fr-FR.json b/packages/@react-spectrum/ai/intl/fr-FR.json index 6c52462a01c..1cb073c9eb8 100644 --- a/packages/@react-spectrum/ai/intl/fr-FR.json +++ b/packages/@react-spectrum/ai/intl/fr-FR.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nouveau message", "messagefeedback.thumbDown": "Réponse incorrecte", "messagefeedback.thumbUp": "Réponse correcte", - "responsestatus.loading": "Chargement" + "responsestatus.loading": "Chargement", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/he-IL.json b/packages/@react-spectrum/ai/intl/he-IL.json index 00e9782d945..c276dde4345 100644 --- a/packages/@react-spectrum/ai/intl/he-IL.json +++ b/packages/@react-spectrum/ai/intl/he-IL.json @@ -2,5 +2,17 @@ "chat.newMessage": "הודעה חדשה", "messagefeedback.thumbDown": "תגובה גרועה", "messagefeedback.thumbUp": "תגובה טובה", - "responsestatus.loading": "בטעינה…" + "responsestatus.loading": "בטעינה…", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/hr-HR.json b/packages/@react-spectrum/ai/intl/hr-HR.json index a0450369a9e..b488f882281 100644 --- a/packages/@react-spectrum/ai/intl/hr-HR.json +++ b/packages/@react-spectrum/ai/intl/hr-HR.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nova poruka", "messagefeedback.thumbDown": "Odgovor nije dobar", "messagefeedback.thumbUp": "Dobar odgovor", - "responsestatus.loading": "Učitavanje" + "responsestatus.loading": "Učitavanje", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/hu-HU.json b/packages/@react-spectrum/ai/intl/hu-HU.json index 29de4b0ea03..33f68cd73a1 100644 --- a/packages/@react-spectrum/ai/intl/hu-HU.json +++ b/packages/@react-spectrum/ai/intl/hu-HU.json @@ -2,5 +2,17 @@ "chat.newMessage": "Új üzenet", "messagefeedback.thumbDown": "Rossz válasz", "messagefeedback.thumbUp": "Jó válasz", - "responsestatus.loading": "Betöltés folyamatban" + "responsestatus.loading": "Betöltés folyamatban", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/it-IT.json b/packages/@react-spectrum/ai/intl/it-IT.json index 2906d5f6c73..40b32e7d1b6 100644 --- a/packages/@react-spectrum/ai/intl/it-IT.json +++ b/packages/@react-spectrum/ai/intl/it-IT.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nuovo messaggio", "messagefeedback.thumbDown": "Risposta scadente", "messagefeedback.thumbUp": "Risposta buona", - "responsestatus.loading": "Caricamento" + "responsestatus.loading": "Caricamento", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/ja-JP.json b/packages/@react-spectrum/ai/intl/ja-JP.json index faf42fa1395..36967d4bb8a 100644 --- a/packages/@react-spectrum/ai/intl/ja-JP.json +++ b/packages/@react-spectrum/ai/intl/ja-JP.json @@ -2,5 +2,17 @@ "chat.newMessage": "新規メッセージ", "messagefeedback.thumbDown": "無効な応答", "messagefeedback.thumbUp": "有効な応答", - "responsestatus.loading": "読み込み中" + "responsestatus.loading": "読み込み中", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/ko-KR.json b/packages/@react-spectrum/ai/intl/ko-KR.json index 945e161b430..ea412902d0e 100644 --- a/packages/@react-spectrum/ai/intl/ko-KR.json +++ b/packages/@react-spectrum/ai/intl/ko-KR.json @@ -2,5 +2,17 @@ "chat.newMessage": "새 메시지", "messagefeedback.thumbDown": "잘못된 응답", "messagefeedback.thumbUp": "양호한 응답", - "responsestatus.loading": "로드 중" + "responsestatus.loading": "로드 중", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/lt-LT.json b/packages/@react-spectrum/ai/intl/lt-LT.json index f7d18bc49e6..6671d294242 100644 --- a/packages/@react-spectrum/ai/intl/lt-LT.json +++ b/packages/@react-spectrum/ai/intl/lt-LT.json @@ -2,5 +2,17 @@ "chat.newMessage": "Naujas pranešimas", "messagefeedback.thumbDown": "Blogas atsakas", "messagefeedback.thumbUp": "Geras atsakas", - "responsestatus.loading": "Įkeliama" + "responsestatus.loading": "Įkeliama", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/lv-LV.json b/packages/@react-spectrum/ai/intl/lv-LV.json index 56fff13710f..07efc451c99 100644 --- a/packages/@react-spectrum/ai/intl/lv-LV.json +++ b/packages/@react-spectrum/ai/intl/lv-LV.json @@ -2,5 +2,17 @@ "chat.newMessage": "Jauns ziņojums", "messagefeedback.thumbDown": "Slikta atbilde", "messagefeedback.thumbUp": "Laba atbilde", - "responsestatus.loading": "Notiek ielāde" + "responsestatus.loading": "Notiek ielāde", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/nb-NO.json b/packages/@react-spectrum/ai/intl/nb-NO.json index bb9bb720567..3fe4208539c 100644 --- a/packages/@react-spectrum/ai/intl/nb-NO.json +++ b/packages/@react-spectrum/ai/intl/nb-NO.json @@ -2,5 +2,17 @@ "chat.newMessage": "Ny melding", "messagefeedback.thumbDown": "Dårlig respons", "messagefeedback.thumbUp": "Bra respons", - "responsestatus.loading": "Laster inn" + "responsestatus.loading": "Laster inn", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/nl-NL.json b/packages/@react-spectrum/ai/intl/nl-NL.json index 07d750ea70e..210f9ec281b 100644 --- a/packages/@react-spectrum/ai/intl/nl-NL.json +++ b/packages/@react-spectrum/ai/intl/nl-NL.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nieuw bericht", "messagefeedback.thumbDown": "Slecht antwoord", "messagefeedback.thumbUp": "Goed antwoord", - "responsestatus.loading": "Laden" + "responsestatus.loading": "Laden", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/pl-PL.json b/packages/@react-spectrum/ai/intl/pl-PL.json index a4f9051ea83..6b4f07e8fd6 100644 --- a/packages/@react-spectrum/ai/intl/pl-PL.json +++ b/packages/@react-spectrum/ai/intl/pl-PL.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nowa wiadomość", "messagefeedback.thumbDown": "Zła odpowiedź", "messagefeedback.thumbUp": "Dobra odpowiedź", - "responsestatus.loading": "Wczytywanie" + "responsestatus.loading": "Wczytywanie", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/pt-BR.json b/packages/@react-spectrum/ai/intl/pt-BR.json index 40dac8c8c41..d704aa2dcac 100644 --- a/packages/@react-spectrum/ai/intl/pt-BR.json +++ b/packages/@react-spectrum/ai/intl/pt-BR.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nova mensagem", "messagefeedback.thumbDown": "Resposta ruim", "messagefeedback.thumbUp": "Resposta boa", - "responsestatus.loading": "Carregando" + "responsestatus.loading": "Carregando", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/pt-PT.json b/packages/@react-spectrum/ai/intl/pt-PT.json index 5ba09b983dd..e0cdc2ca6b3 100644 --- a/packages/@react-spectrum/ai/intl/pt-PT.json +++ b/packages/@react-spectrum/ai/intl/pt-PT.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nova mensagem", "messagefeedback.thumbDown": "Má resposta", "messagefeedback.thumbUp": "Boa resposta", - "responsestatus.loading": "A carregar" + "responsestatus.loading": "A carregar", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/ro-RO.json b/packages/@react-spectrum/ai/intl/ro-RO.json index 34a8f804b8d..d20b60e7aad 100644 --- a/packages/@react-spectrum/ai/intl/ro-RO.json +++ b/packages/@react-spectrum/ai/intl/ro-RO.json @@ -2,5 +2,17 @@ "chat.newMessage": "Mesaj nou", "messagefeedback.thumbDown": "Răspuns greșit", "messagefeedback.thumbUp": "Răspuns bun", - "responsestatus.loading": "Se încarcă" + "responsestatus.loading": "Se încarcă", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/ru-RU.json b/packages/@react-spectrum/ai/intl/ru-RU.json index 45a155a1aa8..e256b6afe54 100644 --- a/packages/@react-spectrum/ai/intl/ru-RU.json +++ b/packages/@react-spectrum/ai/intl/ru-RU.json @@ -2,5 +2,17 @@ "chat.newMessage": "Новое сообщение", "messagefeedback.thumbDown": "Плохой ответ", "messagefeedback.thumbUp": "Хороший ответ", - "responsestatus.loading": "Загрузка" + "responsestatus.loading": "Загрузка", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/sk-SK.json b/packages/@react-spectrum/ai/intl/sk-SK.json index 65a00c5a501..5dd74367b00 100644 --- a/packages/@react-spectrum/ai/intl/sk-SK.json +++ b/packages/@react-spectrum/ai/intl/sk-SK.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nová správa", "messagefeedback.thumbDown": "Zlá odozva", "messagefeedback.thumbUp": "Dobrá odozva", - "responsestatus.loading": "Načítava sa" + "responsestatus.loading": "Načítava sa", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/sl-SI.json b/packages/@react-spectrum/ai/intl/sl-SI.json index 7deee250a34..12e7045e8c5 100644 --- a/packages/@react-spectrum/ai/intl/sl-SI.json +++ b/packages/@react-spectrum/ai/intl/sl-SI.json @@ -2,5 +2,17 @@ "chat.newMessage": "Novo sporočilo", "messagefeedback.thumbDown": "Slab odziv", "messagefeedback.thumbUp": "Dober odziv", - "responsestatus.loading": "Nalaganje" + "responsestatus.loading": "Nalaganje", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/sr-SP.json b/packages/@react-spectrum/ai/intl/sr-SP.json index b6ca2d44e09..be81838c5ce 100644 --- a/packages/@react-spectrum/ai/intl/sr-SP.json +++ b/packages/@react-spectrum/ai/intl/sr-SP.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nova poruka", "messagefeedback.thumbDown": "Loš odgovor", "messagefeedback.thumbUp": "Dobar odgovor", - "responsestatus.loading": "Učitavanje" + "responsestatus.loading": "Učitavanje", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/sv-SE.json b/packages/@react-spectrum/ai/intl/sv-SE.json index f7e3d630a3a..0387c272639 100644 --- a/packages/@react-spectrum/ai/intl/sv-SE.json +++ b/packages/@react-spectrum/ai/intl/sv-SE.json @@ -2,5 +2,17 @@ "chat.newMessage": "Nytt meddelande", "messagefeedback.thumbDown": "Dåligt svar", "messagefeedback.thumbUp": "Bra svar", - "responsestatus.loading": "Läser in" + "responsestatus.loading": "Läser in", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/tr-TR.json b/packages/@react-spectrum/ai/intl/tr-TR.json index 0cdfa0ebf0b..a15863421c6 100644 --- a/packages/@react-spectrum/ai/intl/tr-TR.json +++ b/packages/@react-spectrum/ai/intl/tr-TR.json @@ -2,5 +2,17 @@ "chat.newMessage": "Yeni mesaj", "messagefeedback.thumbDown": "Yanıt kötüydü", "messagefeedback.thumbUp": "Yanıt iyiydi", - "responsestatus.loading": "Yükleniyor" + "responsestatus.loading": "Yükleniyor", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/uk-UA.json b/packages/@react-spectrum/ai/intl/uk-UA.json index fdc04c17932..684b89586d0 100644 --- a/packages/@react-spectrum/ai/intl/uk-UA.json +++ b/packages/@react-spectrum/ai/intl/uk-UA.json @@ -2,5 +2,17 @@ "chat.newMessage": "Нове повідомлення", "messagefeedback.thumbDown": "Погана відповідь", "messagefeedback.thumbUp": "Гарна відповідь", - "responsestatus.loading": "Завантаження" + "responsestatus.loading": "Завантаження", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/zh-CN.json b/packages/@react-spectrum/ai/intl/zh-CN.json index a291059a58c..b71d5edf3ff 100644 --- a/packages/@react-spectrum/ai/intl/zh-CN.json +++ b/packages/@react-spectrum/ai/intl/zh-CN.json @@ -2,5 +2,17 @@ "chat.newMessage": "新消息", "messagefeedback.thumbDown": "错误响应", "messagefeedback.thumbUp": "良好响应", - "responsestatus.loading": "加载" + "responsestatus.loading": "加载", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/intl/zh-TW.json b/packages/@react-spectrum/ai/intl/zh-TW.json index 4cead4c10b2..4b02a94aca3 100644 --- a/packages/@react-spectrum/ai/intl/zh-TW.json +++ b/packages/@react-spectrum/ai/intl/zh-TW.json @@ -2,5 +2,17 @@ "chat.newMessage": "新訊息", "messagefeedback.thumbDown": "不良回答", "messagefeedback.thumbUp": "良好回答", - "responsestatus.loading": "載入中" + "responsestatus.loading": "載入中", + "chat.scrollToBottom": "Scroll to bottom", + "voicebutton.stopListening": "Stop recording", + "voicebutton.startListening": "Start recording", + "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task.", + "promptfield.aiDisclaimer": "Responses are generated using AI, and may be inaccurate. Check before using.", + "promptfield.aiUserGuidlines": "AI User Guidelines", + "promptfield.attachments": "Attachments", + "promptfield.label": "Prompt", + "promptfield.stopButton": "Stop", + "promptfield.submitButton": "Send", + "promptfield.insertButton": "Add", + "promptfield.uploading": "Uploading" } diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index e352fcfabf2..f06de45d8ab 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from 'storybook/actions'; -import {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; import { AttachFileMenuItem, AutoLinkingTokenFieldValue, @@ -352,13 +352,13 @@ function EverythingRender(args) { return (
-
+ {prompts.map((prompt, i) => ( - setValue(prompt)}> + setValue(prompt)}> {prompt.toString()} - + ))} -
+ Date: Mon, 20 Jul 2026 17:08:31 -0700 Subject: [PATCH 23/25] lint and placeholders in tree docs textfields --- .../@react-spectrum/ai/stories/PromptField.stories.tsx | 2 +- packages/dev/s2-docs/pages/react-aria/Tree.mdx | 10 +++++----- packages/dev/s2-docs/pages/s2/TreeView.mdx | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index f06de45d8ab..30783a36dbf 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -11,7 +11,6 @@ */ import {action} from 'storybook/actions'; -import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; import { AttachFileMenuItem, AutoLinkingTokenFieldValue, @@ -47,6 +46,7 @@ import * as data from '../src/loader/data'; import {iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Image} from '@react-spectrum/s2/Image'; import LinkIcon from '@react-spectrum/s2/icons/Link'; +import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; import type {Meta, StoryObj} from '@storybook/react'; import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 7024eb44420..590a4ce1c26 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -341,26 +341,26 @@ import {TextField} from 'vanilla-starter/TextField'; aria-label="Shared files"> - + - + - + - + - + diff --git a/packages/dev/s2-docs/pages/s2/TreeView.mdx b/packages/dev/s2-docs/pages/s2/TreeView.mdx index eb93e63c91a..42f4344300e 100644 --- a/packages/dev/s2-docs/pages/s2/TreeView.mdx +++ b/packages/dev/s2-docs/pages/s2/TreeView.mdx @@ -410,20 +410,20 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; - + - + - + @@ -431,13 +431,13 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; - + - + From d5ab4bc9c396c635ebd2b7cc9b30579968db6a63 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 21 Jul 2026 14:14:07 -0700 Subject: [PATCH 24/25] clean up some comments based on what design mentioned --- packages/@react-spectrum/ai/src/HorizontalCard.tsx | 2 +- packages/@react-spectrum/ai/src/PromptField.tsx | 5 ----- packages/@react-spectrum/ai/src/ResponseStatus.tsx | 5 ----- packages/@react-spectrum/ai/stories/PromptField.stories.tsx | 3 --- 4 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/@react-spectrum/ai/src/HorizontalCard.tsx b/packages/@react-spectrum/ai/src/HorizontalCard.tsx index ca2f1164f30..bdbd2e80f9e 100644 --- a/packages/@react-spectrum/ai/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/ai/src/HorizontalCard.tsx @@ -121,7 +121,7 @@ let card = style({ default: lightDark('transparent-white-300', 'transparent-black-300'), forcedColors: 'ButtonFace' }, - // TODO: for cards that only have images, should they have a red outline around the image thumbnail? + // TODO: waiting for design to investigate thumbnail card/attachement error state boxShadow: { default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 0aa77ee102a..965eb89dbfe 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -84,11 +84,9 @@ export interface PromptFieldAttachment { export interface PromptFieldProps { children: React.ReactNode; acceptedAttachmentTypes?: string[]; - // TODO: mirrors tokenfield, maybe should also be a generic too value?: TokenFieldValue; defaultValue?: TokenFieldValue; onChange?: (value: TokenFieldValue) => void; - // TODO: discuss, I can imagine a case where we also want to prefill these attachments?: PromptFieldAttachment[]; defaultAttachments?: PromptFieldAttachment[]; onAttachmentsChange?: (attachments: PromptFieldAttachment[]) => void; @@ -602,7 +600,6 @@ export function PromptFieldSubmitButton(props: PromptFieldSubmitButtonProps) { export interface PromptFieldVoiceButtonProps { lang?: string; isDisabled?: boolean; - // TODO: coworker renders a toast too, but this gives more flexibility onError?: (code: VoiceInputErrorCode) => void; } @@ -659,8 +656,6 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { applyVoiceTranscript(); }, [transcript, isVoiceListening]); - // TODO this is from coworker, is to stop mutating the input text since the button becomes disabled when the chat - // is streaming, keep it? useEffect(() => { if (isDisabled && isVoiceListening) { stop(); diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index abf07ac1369..7d854c7ae3a 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -331,7 +331,6 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( {status === 'failed' ? (