From ac3c85640f53d798bd2b9974784cee3b2c9d9add Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 4 Sep 2026 19:21:28 -0400 Subject: [PATCH] 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} +
+ +
+ +
- -
- - -
-
-
-
+ + ); }