diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 896eb42c226..841ce794d74 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -10,15 +10,16 @@ * 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 } from 'react-aria-components/CollectionBuilder'; import { createContext, - CSSProperties, ForwardedRef, forwardRef, ReactNode, @@ -34,6 +35,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 +44,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'; @@ -88,12 +90,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 { @@ -159,6 +165,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 @@ -209,7 +216,10 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( return ( -
+
{children}
@@ -265,7 +291,7 @@ export function Thread(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( @@ -288,39 +314,89 @@ export function Thread(props: ThreadProps) { }, [setIsNearBottom, scrollEndThreshold]); return ( - - +
- {children} - - + flexDirection: 'column', + minWidth: 0 + }), + styles + )}> +
+ + + + + +
+ + + {children} + + +
+
); } diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index cb2ef79305b..39723ea5983 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -34,6 +34,7 @@ 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'; @@ -53,7 +54,6 @@ import { TokenSegment } from 'react-stately/useTokenFieldState'; 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 25535f6ae81..c94512ae86f 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') { + 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? + // YL: If we auto-wrap, we would need to move some ThreadItem props (isStreaming, textValue) to the ai component level. Might be strange to have those props when used standalone + 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,160 @@ export function AsyncLoadingChat() { const {messages, isLoadingMore, handleLoadMore, hasMore} = useAsyncMessages(); return ( -
- -
-
- - - - - + + + +
+
- - -
- -
-
- {renderAsyncMessage} -
+
+ {renderAsyncMessage} +
+ +
+ +
- -
- - -
-
-
-
+ + + ); +} + +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: '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() { + 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 = []; + }}> + + + +
); } 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..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'; @@ -229,99 +226,46 @@ 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 d6ac0b1e0c4..5390177fb32 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} + + + ) + } + }} +
+
+
); } ```