From ac3c85640f53d798bd2b9974784cee3b2c9d9add Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 4 Sep 2026 19:21:28 -0400 Subject: [PATCH 01/11] feat: Chat API and design refinements --- packages/@react-spectrum/ai/src/Chat.tsx | 127 +++-- .../ai/stories/Chat.stories.tsx | 530 ++++++------------ 2 files changed, 265 insertions(+), 392 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 896eb42c226..53c53b6f88c 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -10,8 +10,10 @@ * governing permissions and limitations under the License. */ +import {ActionButton} from '@react-spectrum/s2'; import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {ButtonContext} from 'react-aria-components/Button'; +import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import { CollectionRendererContext, createLeafComponent @@ -34,6 +36,7 @@ import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; import {DOMRef, forwardRefType, Node} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {focusRing, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; +// @ts-ignore import { GridList, GridListItem, @@ -42,12 +45,12 @@ import { GridListProps } from 'react-aria-components/GridList'; import {inertValue} from 'react-aria/private/utils/inertValue'; -// @ts-ignore import intlMessages from '../intl/*.json'; import {ListLayout} from './ListLayout'; import {ListStateContext} from 'react-aria-components/ListBox'; import {LoaderNode} from 'react-aria/private/collections/BaseCollection'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; +import {scrollFade} from './tokens.macro' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useFocusWithin} from 'react-aria/useFocusWithin'; @@ -227,7 +230,23 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( } ] ]}> -
+
{children}
@@ -288,39 +307,77 @@ export function Thread(props: ThreadProps) { }, [setIsNearBottom, scrollEndThreshold]); return ( - - - {children} - - +
+ {/* TODO: do we want the scroll button to be optional? */} +
+ + + + + +
+ + + {children} + + +
); } diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 25535f6ae81..3f9c9d17cba 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -16,7 +16,6 @@ import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; import {Chat} from '../src/Chat'; import ChatIcon from '@react-spectrum/s2/icons/Chat'; -import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {Collection} from 'react-aria-components'; import {Content} from '@react-spectrum/s2/Content'; import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; @@ -40,7 +39,6 @@ import { Thread, ThreadItem, ThreadLoadMoreItem, - ThreadScrollButton, TokenFieldValue, UserMessage } from '@react-spectrum/ai'; @@ -483,160 +481,106 @@ export function VirtualizedStreamingChat() { } 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 -
- -
-
- - - - - -
- - {(msg: StreamingMessage) => { - if (msg.type === 'user') { - // TODO: probably want ThreadItem to be a part of UserMessage? - return ( - - {msg.content} - - ); - } - if (msg.type === 'status') { - return ; - } - if (msg.type === 'card') { - return ( - - ); - } - if (msg.type === 'suggestions') { - // TODO: probably should have ThreadItem auto wrap MessageSuggestionList as well - // but this one I could see perhaps being a standalone component to be used outside of thread - return ( - - - {msg.suggestions.map((s, i) => ( - {s} - ))} - - - ); - } - return ( - -
-

{msg.content || ''}

-
- {!msg.isStreaming && } -
- ); - }} -
-
- { - setPromptValue(new PromptFieldValue([])); - handleSend(prompt); - }} - isGenerating={isGenerating} - onStop={handleStop}> - + + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + // TODO: probably want ThreadItem to be a part of UserMessage? + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + return ; + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + // TODO: probably should have ThreadItem auto wrap MessageSuggestionList as well + // but this one I could see perhaps being a standalone component to be used outside of thread + // DG: maybe we could auto-wrap if it's inside a Thread? + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } + return ( + + {/* TODO: make this a component? Build it into SystemMessage? */} +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
+ ); + }} +
+ { + setPromptValue(new PromptFieldValue([])); + handleSend(prompt); + }} + isGenerating={isGenerating} + onStop={handleStop}> + { + if (!isGenerating) { + return; } - onKeyDown={e => { - if (!isGenerating) { - 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(); - if (promptValue.segments.length > 0) { - action('onSteer')(promptValue.toString()); - setPromptValue(new PromptFieldValue([])); - } - } else if (e.key === 'Enter' && e.altKey) { - e.preventDefault(); - if (promptValue.segments.length > 0) { - action('onFollowUp')(promptValue.toString()); - followUpMessage.current = promptValue; - setPromptValue(new PromptFieldValue([])); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - handleStop(); + // 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(); + if (promptValue.segments.length > 0) { + action('onSteer')(promptValue.toString()); + setPromptValue(new PromptFieldValue([])); } - }} - /> - -
- - - - -
+ } else if (e.key === 'Enter' && e.altKey) { + e.preventDefault(); + if (promptValue.segments.length > 0) { + action('onFollowUp')(promptValue.toString()); + followUpMessage.current = promptValue; + setPromptValue(new PromptFieldValue([])); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + handleStop(); + } + }} + /> + +
+ + + + ); } @@ -696,119 +640,65 @@ export function EmptyChat() { } return ( -
- -
-
- - - - - -
- - {(msg: StreamingMessage) => { - if (msg.type === 'user') { - return ( - - {msg.content} - - ); - } - if (msg.type === 'status') { - return ; - } - if (msg.type === 'card') { - return ( - - ); - } - if (msg.type === 'suggestions') { - return ( - - - {msg.suggestions.map((s, i) => ( - {s} - ))} - - - ); - } - return ( - -
-

{msg.content || ''}

-
- {!msg.isStreaming && } -
- ); - }} -
+ + + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + return ; + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } + return ( + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
+ ); + }} +
+ { + setGenerating(false); + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + }}> +
+ +
- { - setGenerating(false); - timeouts.current.forEach(clearTimeout); - timeouts.current = []; - }}> -
- - -
-
-
-
+ +
); } @@ -875,28 +765,8 @@ export function ChatPopover() { - - + + {(msg: PopoverMessage) => { if (msg.type === 'user') { return ( @@ -1236,77 +1106,23 @@ export function AsyncLoadingChat() { const {messages, isLoadingMore, handleLoadMore, hasMore} = useAsyncMessages(); return ( -
- -
-
- - - - - + + + +
+
- - -
- -
-
- {renderAsyncMessage} -
+
+ {renderAsyncMessage} +
+ +
+ +
- -
- - -
-
-
-
+ + ); } From a5043e7d8d07571172cc75697896829be284c62f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:40:37 -0700 Subject: [PATCH 02/11] fix padding when promptfield is size small --- packages/@react-spectrum/ai/src/Chat.tsx | 19 ++- .../@react-spectrum/ai/src/PromptField.tsx | 6 +- .../ai/stories/Chat.stories.tsx | 118 ++++++++++++++++++ 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 53c53b6f88c..76684a7a349 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -91,12 +91,16 @@ interface InternalChatContextValue { announceItem: (text: string) => void; setIsNearBottom: (isNear: boolean) => void; setScrollElement: (element: HTMLElement | null) => void; + promptFieldSize: 'S' | 'M'; + setPromptFieldSize: (size: 'S' | 'M') => void; } -const InternalChatContext = createContext({ +export const InternalChatContext = createContext({ announceItem: text => announce(text, 'polite'), setIsNearBottom: () => {}, - setScrollElement: () => {} + setScrollElement: () => {}, + promptFieldSize: 'M', + setPromptFieldSize: () => {} }); interface ThreadScrollButtonContextValue { @@ -162,6 +166,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( el.scrollTo({top: el.scrollHeight - el.clientHeight, behavior: 'smooth'}); }, []); let [isNearBottom, setIsNearBottom] = useState(true); + let [promptFieldSize, setPromptFieldSize] = useState<'S' | 'M'>('M'); // only announce new items if user is in the prompt field, otherwise if they // are outside the field, only announce there are new responses. If not in chat at all, don't announce @@ -212,7 +217,10 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( return ( (props: ThreadProps) { 'aria-labelledby': ariaLabelledby } = props; - let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext); + let {setIsNearBottom, setScrollElement, promptFieldSize} = useContext(InternalChatContext); let isNearBottomRef = useRef(true); let gridListRef = useRef(null); let callbackRef = useCallback( @@ -338,8 +346,7 @@ export function Thread(props: ThreadProps) { layout={ListLayout} layoutOptions={{ estimatedRowHeight: 100, - // TODO: adjust this for small size prompt field (line up with icon/buttons) - padding: 24, + padding: promptFieldSize === 'S' ? 16 : 24, gap: 16, anchorTo: 'end', loaderSize: 48, diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index cb2ef79305b..4c407ff0593 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -52,8 +52,8 @@ import { TokenFieldValue, TokenSegment } from 'react-stately/useTokenFieldState'; +import {InternalChatContext, PromptFocusContext} from './Chat'; import {PromptFieldContainer} from './PromptFieldContainer'; -import {PromptFocusContext} from './Chat'; import {Provider} from 'react-aria-components/slots'; import {scrollFade} from './tokens.macro' with {type: 'macro'}; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; @@ -329,6 +329,10 @@ export const PromptField = forwardRef(function PromptField( let [isListening, setListening] = useState(false); let {onFocusChange} = useContext(PromptFocusContext); let {focusWithinProps} = useFocusWithin({onFocusWithinChange: onFocusChange}); + let {setPromptFieldSize} = useContext(InternalChatContext); + useEffect(() => { + setPromptFieldSize(size); + }, [setPromptFieldSize, size]); let isPromptControlled = props.value !== undefined; let isAttachmentsControlled = props.attachments !== undefined; diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 3f9c9d17cba..0b13ab6c710 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -1126,3 +1126,121 @@ export function AsyncLoadingChat() { ); } + +let initialMessages = [ + {id: 1, type: 'user', content: 'What\'s a good cat breed for a small apartment?'}, + {id: 2, type: 'assistant', content: 'Russian Blues and British Shorthairs do well in apartments. They\'re calm, quiet, and don\'t need a lot of space to stay happy.'}, + {id: 3, type: 'user', content: 'Do they need a lot of grooming?'}, + {id: 4, type: 'assistant', content: 'Not much. Both have short, dense coats, so a weekly brush is usually enough to keep shedding under control.'}, + {id: 5, type: 'user', content: 'Good to know. Are they okay to leave alone during a full workday?'}, + {id: 6, type: 'assistant', content: 'Yes, they\'re pretty independent. Just make sure they have fresh water, a clean litter box, and a few toys to stay entertained while you\'re out.'} +]; + +export function SmallChat() { + let [messages, setMessages] = useState(initialMessages); + let nextId = useRef(0); + let [isGenerating, setGenerating] = useState(false); + let timeouts = useRef([]); + + function handleSend(prompt: TokenFieldValue) { + setGenerating(true); + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: prompt.toString()} + ]); + + let addTimeout = (callback: () => void, delay: number) => { + let timeout = setTimeout(callback, delay); + timeouts.current.push(timeout); + return timeout; + }; + + let response = DUMMY_RESPONSES[Math.floor(Math.random() * DUMMY_RESPONSES.length)]; + + addTimeout(() => { + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} + ]); + let tokens = response.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + addTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + let isLastToken = i === tokens.length - 1; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? {...m, content: accumulated, isStreaming: !isLastToken} + : m + ) + ); + if (isLastToken) { + setGenerating(false); + } + }, i * 60); + }); + }, 600); + } + + return ( + + + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + return ; + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } + return ( + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
+ ); + }} +
+ { + setGenerating(false); + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + }}> + + + +
+ ); +} \ No newline at end of file From 83d2c20c1b08496354e47cb31abbe08cf2a6148a Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:07:05 -0700 Subject: [PATCH 03/11] update docs --- .../pages/s2/ai-component-helpers/chat.tsx | 135 ++++++------------ .../dev/s2-docs/pages/s2/ai-components.mdx | 117 ++++++++------- 2 files changed, 98 insertions(+), 154 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index 0b25d28d6e6..945cda705f7 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -229,99 +229,48 @@ export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { }, [messages, isGenerating, suggestions]); return ( -
- -
-
- - - - - -
- - {(msg: StreamingMessage) => { - if (msg.type === 'user') { - return ( - - {msg.content} - - ); - } - if (msg.type === 'status') { - return ; - } - if (msg.type === 'suggestions') { - return ( - - - {msg.suggestions.map((s, i) => ( - onSelectSuggestion?.(s)}> - - - ))} - - - ); - } - return ( - -
-

{msg.content || ''}

-
- {!msg.isStreaming && } -
- ); - }} -
-
- {children(handleSend, isGenerating)} -
-
+ + + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + return ; + } + if (msg.type === 'suggestions') { + return ( + + + {msg.suggestions.map((s, i) => ( + onSelectSuggestion?.(s)}> + + + ))} + + + ); + } + return ( + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
+ ); + }} +
+ {children(handleSend, isGenerating)} +
); } diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 9a3b1ee27b2..13ec7b42ac8 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -510,67 +510,62 @@ let messages = [ function BasicChat() { return ( - /*- begin highlight -*/ - - - {message => { - switch (message.type) { - case 'user': - return ( - - {message.text} - - ); - case 'assistant': - return ( - -
{message.content}
-
- ); - case 'status': - return ( - - - {message.text} - - - {message.steps?.map(step => ( - {step.detail}

}> - {step.label} -
- ))} -
-
-
-
- ); - case 'alert': - return ( - - - {message.text} - - - ) - } - }} -
-
+
+ {/*- begin highlight -*/} + + {/*- end highlight -*/} + + {message => { + switch (message.type) { + case 'user': + return ( + + {message.text} + + ); + case 'assistant': + return ( + +
{message.content}
+
+ ); + case 'status': + return ( + + + {message.text} + + + {message.steps?.map(step => ( + {step.detail}

}> + {step.label} +
+ ))} +
+
+
+
+ ); + case 'alert': + return ( + + + {message.text} + + + ) + } + }} +
+
+
); } ``` From 23a054d7ebc31164d3b5fff4ecbdb27277ab3ed5 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:17:40 -0700 Subject: [PATCH 04/11] fix lint --- .../ai/stories/Chat.stories.tsx | 33 +++++++++++++++---- .../pages/s2/ai-component-helpers/chat.tsx | 4 +-- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 0b13ab6c710..a35e7009c15 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -1127,13 +1127,32 @@ export function AsyncLoadingChat() { ); } -let initialMessages = [ - {id: 1, type: 'user', content: 'What\'s a good cat breed for a small apartment?'}, - {id: 2, type: 'assistant', content: 'Russian Blues and British Shorthairs do well in apartments. They\'re calm, quiet, and don\'t need a lot of space to stay happy.'}, +let initialMessages: StreamingMessage[] = [ + {id: 1, type: 'user', content: "What's a good cat breed for a small apartment?"}, + { + id: 2, + type: 'system', + content: + "Russian Blues and British Shorthairs do well in apartments. They're calm, quiet, and don't need a lot of space to stay happy." + }, {id: 3, type: 'user', content: 'Do they need a lot of grooming?'}, - {id: 4, type: 'assistant', content: 'Not much. Both have short, dense coats, so a weekly brush is usually enough to keep shedding under control.'}, - {id: 5, type: 'user', content: 'Good to know. Are they okay to leave alone during a full workday?'}, - {id: 6, type: 'assistant', content: 'Yes, they\'re pretty independent. Just make sure they have fresh water, a clean litter box, and a few toys to stay entertained while you\'re out.'} + { + id: 4, + type: 'system', + content: + 'Not much. Both have short, dense coats, so a weekly brush is usually enough to keep shedding under control.' + }, + { + id: 5, + type: 'user', + content: 'Good to know. Are they okay to leave alone during a full workday?' + }, + { + id: 6, + type: 'system', + content: + "Yes, they're pretty independent. Just make sure they have fresh water, a clean litter box, and a few toys to stay entertained while you're out." + } ]; export function SmallChat() { @@ -1243,4 +1262,4 @@ export function SmallChat() { ); -} \ No newline at end of file +} diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index 945cda705f7..d61058cb977 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -230,9 +230,7 @@ export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { return ( - + {(msg: StreamingMessage) => { if (msg.type === 'user') { return ( From 6c1ccccf59742c828f3265a20a43d4e7d26df671 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:40:50 -0700 Subject: [PATCH 05/11] wrap user message in thread item if in thread --- packages/@react-spectrum/ai/src/Chat.tsx | 149 +++++++++--------- .../@react-spectrum/ai/src/UserMessage.tsx | 17 +- .../ai/stories/Chat.stories.tsx | 39 +---- 3 files changed, 98 insertions(+), 107 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 76684a7a349..4d1caeb5131 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -115,6 +115,12 @@ const ThreadScrollButtonContext = createContext( 'aria-label': '' }); +interface ThreadContextProps { + isInThread?: boolean; +} + +export const ThreadContext = createContext({}); + // TODO: make this more RAC like (aka default class name and other RAC prop) export interface ChatProps { /** @@ -315,76 +321,78 @@ export function Thread(props: ThreadProps) { }, [setIsNearBottom, scrollEndThreshold]); return ( -
- {/* TODO: do we want the scroll button to be optional? */} +
- - - - - + className={mergeStyles( + style({ + position: 'relative', + flexGrow: 1, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + minWidth: 0 + }), + styles + )}> + {/* TODO: do we want the scroll button to be optional? */} +
+ + + + + +
+ + + {children} + +
- - - {children} - - -
+
); } @@ -437,9 +445,8 @@ const threadItemBase = style({ borderRadius: 'default' }); -export interface ThreadItemProps extends Pick< - GridListItemProps, - 'textValue' | 'focusMode' | 'allowsArrowNavigation' | 'id' +export interface ThreadItemProps extends Partial< + Pick > { /** * Spectrum-defined styles, returned by the `style()` macro. diff --git a/packages/@react-spectrum/ai/src/UserMessage.tsx b/packages/@react-spectrum/ai/src/UserMessage.tsx index 533df340ea8..2185c1423f4 100644 --- a/packages/@react-spectrum/ai/src/UserMessage.tsx +++ b/packages/@react-spectrum/ai/src/UserMessage.tsx @@ -13,11 +13,12 @@ import {AriaLabelingProps, DOMProps, DOMRef} from '@react-types/shared'; import {DEFAULT_SLOT, Provider, SlotProps} from 'react-aria-components/slots'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {forwardRef, ReactNode} from 'react'; +import {forwardRef, ReactNode, useContext} from 'react'; import {ImageContext} from '@react-spectrum/s2/Image'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ThreadContext, ThreadItem} from './Chat'; import {useDOMRef} from './useDOMRef'; export interface UserMessageProps extends DOMProps, AriaLabelingProps, SlotProps { @@ -77,8 +78,10 @@ export const UserMessage = forwardRef(function UserMessage( ) { let domRef = useDOMRef(ref); let {children, styles} = props; + let ctx = useContext(ThreadContext); + let isInThread = ctx.isInThread; - return ( + let userMessage = (
); + + if (isInThread) { + return ( + + {userMessage} + + ); + } + + return userMessage; }); diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index a35e7009c15..8eb9435f32d 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -485,14 +485,7 @@ export function VirtualizedStreamingChat() { {(msg: StreamingMessage) => { if (msg.type === 'user') { - // TODO: probably want ThreadItem to be a part of UserMessage? - return ( - - {msg.content} - - ); + return {msg.content}; } if (msg.type === 'status') { return ; @@ -644,13 +637,7 @@ export function EmptyChat() { {(msg: StreamingMessage) => { if (msg.type === 'user') { - return ( - - {msg.content} - - ); + return {msg.content}; } if (msg.type === 'status') { return ; @@ -769,13 +756,7 @@ export function ChatPopover() { {(msg: PopoverMessage) => { if (msg.type === 'user') { - return ( - - {msg.content} - - ); + return {msg.content}; } return ( @@ -1060,11 +1041,7 @@ const PAGE_SIZE = 10; function renderAsyncMessage(msg: AsyncMessage) { if (msg.role === 'user') { - return ( - - {msg.content} - - ); + return {msg.content}; } return ( @@ -1207,13 +1184,7 @@ export function SmallChat() { {(msg: StreamingMessage) => { if (msg.type === 'user') { - return ( - - {msg.content} - - ); + return {msg.content}; } if (msg.type === 'status') { return ; From 006b397b1459c23278acfb36980a21288796be34 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:06:43 -0700 Subject: [PATCH 06/11] fix lint --- packages/@react-spectrum/ai/src/PromptField.tsx | 2 +- packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 4c407ff0593..a616b1ab04d 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -37,6 +37,7 @@ import {IconContext, MenuTriggerProps} from '@react-spectrum/s2'; // @ts-ignore import intlMessages from '../intl/*.json'; import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; +import {InternalChatContext, PromptFocusContext} from './Chat'; import {Link} from '@react-spectrum/s2/Link'; import {LinkButtonContext} from '@react-spectrum/s2/LinkButton'; import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; @@ -52,7 +53,6 @@ import { TokenFieldValue, TokenSegment } from 'react-stately/useTokenFieldState'; -import {InternalChatContext, PromptFocusContext} from './Chat'; import {PromptFieldContainer} from './PromptFieldContainer'; import {Provider} from 'react-aria-components/slots'; import {scrollFade} from './tokens.macro' with {type: 'macro'}; diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index d61058cb977..b6d329ff09b 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -1,6 +1,4 @@ -import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; -import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {getIcon} from './promptfield'; import { Chat, @@ -14,7 +12,6 @@ import { ResponseStatusTitle, Thread, ThreadItem, - ThreadScrollButton, TokenFieldValue, UserMessage } from '@react-spectrum/ai'; From 3ffdb8d3894587fde67fe9285e2990eebbc1021f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:10:24 -0700 Subject: [PATCH 07/11] alphabet is hard --- packages/@react-spectrum/ai/src/PromptField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index a616b1ab04d..39723ea5983 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -34,10 +34,10 @@ import { import {FocusableRef} from '@react-types/shared'; import {getInteractionModality} from 'react-aria/private/interactions/useFocusVisible'; import {IconContext, MenuTriggerProps} from '@react-spectrum/s2'; +import {InternalChatContext, PromptFocusContext} from './Chat'; // @ts-ignore import intlMessages from '../intl/*.json'; import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; -import {InternalChatContext, PromptFocusContext} from './Chat'; import {Link} from '@react-spectrum/s2/Link'; import {LinkButtonContext} from '@react-spectrum/s2/LinkButton'; import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; From b1f6be05d31877360464c56df2b97da686d0fb56 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:35:05 -0700 Subject: [PATCH 08/11] chat style consolidation --- packages/@react-spectrum/ai/src/Chat.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 4d1caeb5131..fa714e51355 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -370,23 +370,24 @@ export function Thread(props: ThreadProps) { aria-label={ariaLabel} aria-labelledby={ariaLabelledby} // TODO: for now we enforce this, but to be configurable? - style={ - { - display: 'flex', - boxSizing: 'border-box', - minWidth: 0, - scrollbarGutter: 'stable' - } as CSSProperties - } className={ scrollFade({y: 32}) + ' ' + style({ + display: 'flex', + boxSizing: 'border-box', + minWidth: 0, + scrollbarGutter: 'stable', flexGrow: 1, overflowX: 'hidden', overflowY: 'auto', - scrollPadding: 24 - }) + scrollPadding: { + default: 24, + promptFieldSize: { + S: 16 + } + } + })({promptFieldSize}) }> {children} From 8c5e1d93eaa607547539da8cc69953e073b4a793 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:10:40 -0700 Subject: [PATCH 09/11] fix lint --- packages/@react-spectrum/ai/src/Chat.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index fa714e51355..a0409ca29ce 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -20,7 +20,6 @@ import { } from 'react-aria-components/CollectionBuilder'; import { createContext, - CSSProperties, ForwardedRef, forwardRef, ReactNode, From ee4511525e9cffb76b0ac3ed3dceeaaa57480299 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:37:28 -0700 Subject: [PATCH 10/11] Revert "wrap user message in thread item if in thread" This reverts commit 6c1ccccf59742c828f3265a20a43d4e7d26df671. --- packages/@react-spectrum/ai/src/Chat.tsx | 27 ++++++++----- .../@react-spectrum/ai/src/UserMessage.tsx | 17 +------- .../ai/stories/Chat.stories.tsx | 39 ++++++++++++++++--- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index a0409ca29ce..cf5d821f1fe 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -114,12 +114,6 @@ const ThreadScrollButtonContext = createContext( 'aria-label': '' }); -interface ThreadContextProps { - isInThread?: boolean; -} - -export const ThreadContext = createContext({}); - // TODO: make this more RAC like (aka default class name and other RAC prop) export interface ChatProps { /** @@ -320,7 +314,19 @@ export function Thread(props: ThreadProps) { }, [setIsNearBottom, scrollEndThreshold]); return ( - +
+ {/* TODO: do we want the scroll button to be optional? */}
(props: ThreadProps) {
- +
); } @@ -445,8 +451,9 @@ const threadItemBase = style({ borderRadius: 'default' }); -export interface ThreadItemProps extends Partial< - Pick +export interface ThreadItemProps extends Pick< + GridListItemProps, + 'textValue' | 'focusMode' | 'allowsArrowNavigation' | 'id' > { /** * Spectrum-defined styles, returned by the `style()` macro. diff --git a/packages/@react-spectrum/ai/src/UserMessage.tsx b/packages/@react-spectrum/ai/src/UserMessage.tsx index 2185c1423f4..533df340ea8 100644 --- a/packages/@react-spectrum/ai/src/UserMessage.tsx +++ b/packages/@react-spectrum/ai/src/UserMessage.tsx @@ -13,12 +13,11 @@ import {AriaLabelingProps, DOMProps, DOMRef} from '@react-types/shared'; import {DEFAULT_SLOT, Provider, SlotProps} from 'react-aria-components/slots'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {forwardRef, ReactNode, useContext} from 'react'; +import {forwardRef, ReactNode} from 'react'; import {ImageContext} from '@react-spectrum/s2/Image'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {ThreadContext, ThreadItem} from './Chat'; import {useDOMRef} from './useDOMRef'; export interface UserMessageProps extends DOMProps, AriaLabelingProps, SlotProps { @@ -78,10 +77,8 @@ export const UserMessage = forwardRef(function UserMessage( ) { let domRef = useDOMRef(ref); let {children, styles} = props; - let ctx = useContext(ThreadContext); - let isInThread = ctx.isInThread; - let userMessage = ( + return (
); - - if (isInThread) { - return ( - - {userMessage} - - ); - } - - return userMessage; }); diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 8eb9435f32d..a35e7009c15 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -485,7 +485,14 @@ export function VirtualizedStreamingChat() { {(msg: StreamingMessage) => { if (msg.type === 'user') { - return {msg.content}; + // TODO: probably want ThreadItem to be a part of UserMessage? + return ( + + {msg.content} + + ); } if (msg.type === 'status') { return ; @@ -637,7 +644,13 @@ export function EmptyChat() { {(msg: StreamingMessage) => { if (msg.type === 'user') { - return {msg.content}; + return ( + + {msg.content} + + ); } if (msg.type === 'status') { return ; @@ -756,7 +769,13 @@ export function ChatPopover() { {(msg: PopoverMessage) => { if (msg.type === 'user') { - return {msg.content}; + return ( + + {msg.content} + + ); } return ( @@ -1041,7 +1060,11 @@ const PAGE_SIZE = 10; function renderAsyncMessage(msg: AsyncMessage) { if (msg.role === 'user') { - return {msg.content}; + return ( + + {msg.content} + + ); } return ( @@ -1184,7 +1207,13 @@ export function SmallChat() { {(msg: StreamingMessage) => { if (msg.type === 'user') { - return {msg.content}; + return ( + + {msg.content} + + ); } if (msg.type === 'status') { return ; From 5c483be72afdc9124c4bfb17fdd57bcd2d236fa9 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:20:07 -0700 Subject: [PATCH 11/11] cleanup --- packages/@react-spectrum/ai/src/Chat.tsx | 2 -- packages/@react-spectrum/ai/stories/Chat.stories.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index cf5d821f1fe..841ce794d74 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -326,7 +326,6 @@ export function Thread(props: ThreadProps) { }), styles )}> - {/* TODO: do we want the scroll button to be optional? */}
(props: ThreadProps) { }), styles )}> - {/* TODO: do we want the scroll button to be optional? */}
{(msg: StreamingMessage) => { if (msg.type === 'user') { - // TODO: probably want ThreadItem to be a part of UserMessage? return (