Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4b437d0
wip: PromptField styling
devongovett Jun 19, 2026
4152303
fix tests
devongovett Jun 19, 2026
ff6e872
fix: use os.tmpdir() for docgen socket when project path is too long
devongovett Jun 19, 2026
2403e41
light mode styles
devongovett Jun 19, 2026
20cbb3b
tsc
devongovett Jun 20, 2026
3f49db4
wip: loader animation
devongovett Jun 20, 2026
e2b4422
Make PixelLoader tree shakeable
devongovett Jun 22, 2026
f5dda5c
Implement brand color theming
devongovett Jun 22, 2026
2a94c16
simplify poster frame
devongovett Jun 22, 2026
97c411f
update token styling
devongovett Jun 23, 2026
9221bb7
Fix focus state to only handle trusted events
devongovett Jun 23, 2026
1430046
fix TokenField shift selection
devongovett Jun 23, 2026
461b81a
use native browser selection modification to handle rtl
devongovett Jun 24, 2026
c8a40c3
Implement disabled/readonly
devongovett Jun 24, 2026
54403a6
fix bug
devongovett Jun 24, 2026
e23381a
harden json parsing
devongovett Jun 24, 2026
5259b8d
Only move the caret when the field is focused
devongovett Jun 24, 2026
53dd33e
fix tagfield story
devongovett Jun 24, 2026
858aa1e
test multiline behavior
devongovett Jun 24, 2026
f660093
simplify and improve combobox tagfield example
devongovett Jun 24, 2026
168e199
refactor combobox into reusable subclass
devongovett Jun 24, 2026
abf5d52
use generic type for token values
devongovett Jun 25, 2026
4be301f
Improve IME composition event handling
devongovett Jun 25, 2026
57f6679
refactor composition event handling to emit onChange but not re-render
devongovett Jun 26, 2026
3b6bd4f
cleanup
devongovett Jun 26, 2026
7ef8c33
Merge branch 'main' of github.com:adobe/react-spectrum into promptfie…
devongovett Jul 7, 2026
1fa7a92
lint
devongovett Jul 7, 2026
b293c4c
Merge branch 'main' of github.com:adobe/react-spectrum into promptfie…
devongovett Jul 9, 2026
15e31c9
update pixel loader
devongovett Jul 10, 2026
876e3b9
cleanup
devongovett Jul 10, 2026
e1e3155
lint
devongovett Jul 10, 2026
bbb94eb
more cleanup
devongovett Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 112 additions & 117 deletions packages/@react-spectrum/ai/src/PromptField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton';
import Attach from '@react-spectrum/s2/icons/Attach';
import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList';
import {Autocomplete} from 'react-aria-components/Autocomplete';
import {baseColor, css, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'};
import {Button} from '@react-spectrum/s2/Button';
import {Cell} from './loader/data';
import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline';
import {color, css, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'};
import {
createContext,
createRef,
Expand All @@ -27,26 +28,23 @@ import {
useRef,
useState
} from 'react';
// eslint-disable-next-line
import {
Direction,
Position,
TokenFieldSegment,
TokenSegment,
TokenSegmentList
} from './TokenSegmentList';
import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions';
import {Group} from 'react-aria-components/Group';
import {IconContext, mergeStyles} from '@react-spectrum/s2';
import {IconContext} from '@react-spectrum/s2';
import {Image, Text} from '@react-spectrum/s2/Card';
import {isFileDropItem, useDrop} from 'react-aria-components/useDrop';
import {isFocusable} from 'react-aria/private/utils/isFocusable';
import {Link} from '@react-spectrum/s2/Link';
import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu';
// eslint-disable-next-line
import {PixelLoader} from './loader/react';
import Plus from '@react-spectrum/s2/icons/Add';
import {Popover, PopoverProps} from '@react-spectrum/s2/Popover';
import {positionToDOMRange, Token, TokenField, TokenProps} from './TokenField';
import {PromptFieldContainer} from './PromptFieldContainer';
import {PromptFocusContext} from './Chat';
import Send from '@react-spectrum/s2/icons/ArrowUpSend';
import Stop from '@react-spectrum/s2/icons/StopProcessing';
Expand All @@ -67,6 +65,7 @@ export interface PromptFieldProps {
onAddAttachments?: (attachments: PromptFieldAttachment[]) => void;
onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void;
styles?: StyleString;
variant?: 'balanced' | 'prominent' | 'subtle';
}

interface PromptFieldState {
Expand Down Expand Up @@ -141,15 +140,16 @@ export function PromptField(props: PromptFieldProps) {
onStop,
styles,
onAddAttachments,
onRemoveAttachments
onRemoveAttachments,
variant = 'balanced'
} = props;
let [prompt, setPrompt] = useState<TokenSegmentList>(new AutoLinkingSegmentList([]));
let [attachments, setAttachments] = useState<PromptFieldAttachment[]>([]);

// Not using RAC DropZone because it adds its own focusable button,
// and we want to avoid an extra tab. We support pasting files directly into the input.
let inputRef = useRef<HTMLDivElement>(null);
let {dropProps, isDropTarget} = useDrop({
let {dropProps} = useDrop({
ref: inputRef,
hasDropButton: true,
isDisabled: !acceptedAttachmentTypes,
Expand Down Expand Up @@ -202,47 +202,15 @@ export function PromptField(props: PromptFieldProps) {
onRemoveAttachments
}}>
<div {...focusWithinProps}>
<Group
<PromptFieldContainer
{...dropProps}
role="group"
className={renderProps =>
mergeStyles(
style({
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 16,
boxShadow: 'emphasized',
backgroundColor: {
default: 'elevated',
isDropTarget: 'blue-200'
},
borderRadius: 'lg',
borderWidth: 2,
borderStyle: 'solid',
borderColor: {
default: 'transparent',
isDropTarget: 'blue-800'
},
cursor: 'text'
})({...renderProps, isDropTarget}),
styles
)
}
onPointerDown={e => {
// If not clicking on something focusable within the prompt field, focus the input.
let target = getEventTarget(e) as Element | null;
while (target && target !== e.currentTarget && !isFocusable(target)) {
target = target.parentElement;
}

if (target === e.currentTarget) {
e.preventDefault();
inputRef.current?.focus();
}
}}>
variant={variant}
isGenerating={isGenerating ?? false}
styles={styles}
inputRef={inputRef}>
{children}
</Group>
</PromptFieldContainer>
<p className={style({font: 'ui-sm', textAlign: 'center'})}>
Responses are generated using AI, and may be inaccurate. Check before using.{' '}
<Link
Expand Down Expand Up @@ -297,18 +265,20 @@ export interface PromptTokenFieldProps {
filterValue: string
) => React.ReactNode[] | null | Promise<React.ReactNode[] | null>;
children?: (segment: TokenSegment) => React.ReactElement;
pixelLoader?: Cell[] | Cell[][];
}

export function PromptTokenField(props: PromptTokenFieldProps) {
let {completionTrigger, renderCompletions, children} = props;
let {completionTrigger, renderCompletions, children, pixelLoader} = props;
let {
prompt,
setPrompt,
acceptedAttachmentTypes,
setAttachments,
onAddAttachments,
inputRef,
onSubmit
onSubmit,
isGenerating
} = useContext(PromptFieldContext);
let [isFocused, setFocused] = useState(false);

Expand All @@ -332,71 +302,89 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
}, [filterValue, renderCompletions]);

return (
<Autocomplete>
<TokenField
value={prompt}
onChange={setPrompt}
multiline
aria-label="Prompt"
data-placeholder="Ready to get started? Ask a question, share an idea, or add a task."
ref={inputRef}
onFocus={e => {
if (e.isTrusted) {
setFocused(true);
}
}}
onBlur={e => {
if (e.isTrusted) {
setFocused(false);
}
}}
onPaste={
acceptedAttachmentTypes
? e => {
let clipboardData = e.clipboardData as DataTransfer;
let attachments: PromptFieldAttachment[] = [];
for (let item of clipboardData.items) {
if (matchMimeType(item.type, acceptedAttachmentTypes)) {
let file = item.getAsFile()!;
attachments.push({
id: crypto.randomUUID(),
file,
image: file.type.startsWith('image/') ? URL.createObjectURL(file) : ''
});
<div
className={style({
display: 'flex',
gap: 12,
alignItems: 'baseline',
color: {
default: 'transparent-overlay-600',
isFocused: 'body'
},
transition: 'default',
paddingStart: 4,
width: 'full'
})({isFocused: isFocused || prompt.segments.length > 0})}>
<CenterBaseline>
<PixelLoader playing={isGenerating} icon={pixelLoader} />
</CenterBaseline>
<Autocomplete>
<TokenField
value={prompt}
onChange={setPrompt}
multiline
aria-label="Prompt"
data-placeholder="Ready to get started? Ask a question, share an idea, or add a task."
ref={inputRef}
onFocus={e => {
if (e.isTrusted) {
setFocused(true);
}
}}
onBlur={e => {
if (e.isTrusted) {
setFocused(false);
}
}}
onPaste={
acceptedAttachmentTypes
? e => {
let clipboardData = e.clipboardData as DataTransfer;
let attachments: PromptFieldAttachment[] = [];
for (let item of clipboardData.items) {
if (matchMimeType(item.type, acceptedAttachmentTypes)) {
let file = item.getAsFile()!;
attachments.push({
id: crypto.randomUUID(),
file,
image: file.type.startsWith('image/') ? URL.createObjectURL(file) : ''
});
}
}
if (attachments.length > 0) {
onAddAttachments?.(attachments);
setAttachments(prev => [...prev, ...attachments]);
}
}
if (attachments.length > 0) {
onAddAttachments?.(attachments);
setAttachments(prev => [...prev, ...attachments]);
: undefined
}
onSubmit={onSubmit}
className={
css('&:empty::before { content: attr(data-placeholder); }') +
style({
flexGrow: 1,
font: 'body',
color: {
default: 'gray-800',
':empty': {
default: 'transparent-overlay-600',
forcedColors: 'GrayText'
}
}
: undefined
}
onSubmit={onSubmit}
className={renderProps =>
css('&:empty::before { content: attr(data-placeholder); }') +
style({
font: 'body',
color: {
default: baseColor('neutral'),
':empty': {
default: 'gray-600',
forcedColors: 'GrayText'
}
},
width: 'full',
outlineStyle: 'none',
cursor: 'text'
})(renderProps)
}>
{children || (segment => <PromptToken>{segment.text}</PromptToken>)}
</TokenField>
<PromptTokenFieldPopover
filterAnchor={filterAnchor}
items={useDeferredValue(items)}
isFocused={isFocused}
/>
</Autocomplete>
},
width: 'full',
outlineStyle: 'none',
cursor: 'text'
})
}>
{children || (segment => <PromptToken>{segment.text}</PromptToken>)}
</TokenField>
<PromptTokenFieldPopover
filterAnchor={filterAnchor}
items={useDeferredValue(items)}
isFocused={isFocused}
/>
</Autocomplete>
</div>
);
}

Expand Down Expand Up @@ -462,18 +450,24 @@ export function PromptToken(props: PromptTokenProps) {
<Token
{...props}
className={style({
font: 'ui',
backgroundColor: {
default: 'blue-300',
default: 'transparent-overlay-1000/10',
isSelected: 'blue-800',
'::selection': 'transparent'
},
color: {
default: 'blue-1000',
default: 'body',
isSelected: 'white'
},
borderRadius: 'sm',
paddingX: 4,
paddingY: 2,
outlineStyle: 'solid',
outlineWidth: 1,
outlineColor: 'transparent-overlay-1000/10',
outlineOffset: -1,
borderRadius: 'pill',
boxShadow: `[inset 0 24px 32px 0 ${color('transparent-white-50')}, 0 8px 32px 0 ${color('transparent-black-50')}]`,
paddingX: 8,
paddingY: 4,
lineHeight: '[1em]',
cursor: 'default',
'--iconPrimary': {
Expand Down Expand Up @@ -519,6 +513,7 @@ export function PromptFieldSubmitButton(props: PromptFieldSubmitButtonProps) {
return (
<Button
variant="primary"
staticColor="auto"
// TODO: should it be possible to submit a prompt with only attachments?
isDisabled={prompt.segments.length === 0 && !isGenerating}
aria-label={isGenerating ? 'Stop' : 'Send'}
Expand All @@ -536,7 +531,7 @@ export function InsertMenuButton(props: InsertMenuItemProps) {
let {children} = props;
return (
<MenuTrigger>
<ActionButton isQuiet aria-label="Add">
<ActionButton isQuiet staticColor="auto" aria-label="Add">
<Plus />
</ActionButton>
<Menu>{children}</Menu>
Expand Down
Loading