From 368f76f5a8ec48d1b33557c6e21361f8fc7afb6e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 8 Jun 2026 16:39:05 -0700 Subject: [PATCH 1/8] fix usermessage text wrapping in thread story and focus newest item when pressing the scroll down button --- packages/@react-spectrum/s2-ai/src/Thread.tsx | 17 ++++++++++++++++- .../s2-ai/stories/Thread.stories.tsx | 17 +++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/@react-spectrum/s2-ai/src/Thread.tsx b/packages/@react-spectrum/s2-ai/src/Thread.tsx index f2beefb9c81..e57071fe1bd 100644 --- a/packages/@react-spectrum/s2-ai/src/Thread.tsx +++ b/packages/@react-spectrum/s2-ai/src/Thread.tsx @@ -82,7 +82,22 @@ export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thre let scrollRef = useRef(null); let scrollToBottom = useCallback(() => { - scrollRef.current?.scrollTo({top: 0, behavior: 'smooth'}); + let el = scrollRef.current; + if (!el) { + return; + } + // TODO: will need some kind of api to programatically set the focused item to + // the newest item in the gridlist in the virtualizer case. this works for + // non-virtualized for now though + el.addEventListener( + 'scrollend', + () => { + let firstRow = el.querySelector('[role="row"]'); + (firstRow ?? el).focus(); + }, + {once: true} + ); + el.scrollTo({top: 0, behavior: 'smooth'}); }, []); let [isNearBottom, setIsNearBottom] = useState(true); diff --git a/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx b/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx index faefe2e288c..96d4e95760d 100644 --- a/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx +++ b/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx @@ -402,8 +402,7 @@ export function StreamingThread() { overflow: 'auto', padding: 8, scrollPadding: 8, - rowGap: 16, - alignItems: 'start' + rowGap: 16 })}> {(msg: StreamingMessage) => { if (msg.type === 'user') { @@ -411,7 +410,12 @@ export function StreamingThread() { return ( + className={style({ + ...focusRing(), + borderRadius: 'default', + display: 'flex', + justifyContent: 'end' + })}> {msg.content} ); @@ -534,7 +538,12 @@ export function VirtualizedThread() { return ( + className={style({ + ...focusRing(), + borderRadius: 'lg', + display: 'flex', + justifyContent: 'end' + })}> {msg.content} ); From c4db0b0bddb444c1927a3ba75cd276f1150d4581 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 8 Jun 2026 17:14:10 -0700 Subject: [PATCH 2/8] fix so tabbing from thread feedback buttons properly exits the thread used to hit every toggle button group in the thread before exiting --- packages/react-aria/src/toolbar/useToolbar.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-aria/src/toolbar/useToolbar.ts b/packages/react-aria/src/toolbar/useToolbar.ts index 00b0b54b28c..bedd41b674a 100644 --- a/packages/react-aria/src/toolbar/useToolbar.ts +++ b/packages/react-aria/src/toolbar/useToolbar.ts @@ -89,7 +89,6 @@ export function useToolbar( // out of the entire toolbar. To do this, move focus // to the first or last focusable child, and let the // browser handle the Tab key as usual from there. - e.stopPropagation(); lastFocused.current = getActiveElement() as HTMLElement; if (e.shiftKey) { focusManager.focusFirst(); From 83eb12897f6cf8ba61a1d77080394fb2c1ad64df Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 11 Jun 2026 10:42:37 -0700 Subject: [PATCH 3/8] rename to Chat and Thread, make new prop unstable --- .../s2-ai/src/{Thread.tsx => Chat.tsx} | 28 +++++++++---------- .../{Thread.stories.tsx => Chat.stories.tsx} | 24 ++++++++-------- ...browser.test.tsx => Chat.browser.test.tsx} | 10 +++---- .../test/{Thread.test.tsx => Chat.test.tsx} | 24 ++++++++-------- .../react-aria-components/src/GridList.tsx | 15 ++++++---- .../react-aria/src/gridlist/useGridList.ts | 4 +-- .../src/selection/useSelectableCollection.ts | 16 ++++++----- 7 files changed, 64 insertions(+), 57 deletions(-) rename packages/@react-spectrum/s2-ai/src/{Thread.tsx => Chat.tsx} (92%) rename packages/@react-spectrum/s2-ai/stories/{Thread.stories.tsx => Chat.stories.tsx} (98%) rename packages/@react-spectrum/s2-ai/test/{Thread.browser.test.tsx => Chat.browser.test.tsx} (89%) rename packages/@react-spectrum/s2-ai/test/{Thread.test.tsx => Chat.test.tsx} (93%) diff --git a/packages/@react-spectrum/s2-ai/src/Thread.tsx b/packages/@react-spectrum/s2-ai/src/Chat.tsx similarity index 92% rename from packages/@react-spectrum/s2-ai/src/Thread.tsx rename to packages/@react-spectrum/s2-ai/src/Chat.tsx index e57071fe1bd..e990013fdd4 100644 --- a/packages/@react-spectrum/s2-ai/src/Thread.tsx +++ b/packages/@react-spectrum/s2-ai/src/Chat.tsx @@ -36,14 +36,14 @@ import {TextFieldContext} from 'react-aria-components/TextField'; import {useDOMRef} from './useDOMRef'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; -interface InternalThreadContextValue { +interface InternalChatContextValue { announceItem: (text: string) => void; setGridListFocused: (isFocused: boolean) => void; setIsNearBottom: (isNear: boolean) => void; setScrollElement: (element: HTMLElement | null) => void; } -const InternalThreadContext = createContext({ +const InternalChatContext = createContext({ announceItem: text => announce(text, 'polite'), setGridListFocused: () => {}, setIsNearBottom: () => {}, @@ -61,7 +61,7 @@ const ThreadScrollButtonContext = createContext( }); // TODO: make this more RAC like (aka default class name and other RAC prop) -interface ThreadProps { +interface ChatProps { className?: string; style?: CSSProperties; children?: ReactNode; @@ -69,8 +69,8 @@ interface ThreadProps { // TODO: tabbing is a bit broken as well since we hit the child elements of the gridlist rows in opposite order... This seems to be due to the // tabIndex = 0 of the ToggleButtons in the ToggleButtonGroup -export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thread( - props: ThreadProps, +export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( + props: ChatProps, ref: DOMRef ) { let {children, className, style} = props; @@ -102,7 +102,7 @@ export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thre let [isNearBottom, setIsNearBottom] = useState(true); // only announce new items if user is in the prompt field, otherwise if they - // are in the thread only announce there are new responses. If not in thread, don't announce + // are outside the field, only announce there are new responses. If not in chat at all, don't announce let announceItem = useCallback((text: string) => { if (isGridListFocusedRef.current) { // TODO: ideally announce number of new messages, but only count system messages? maybe threaditem needs @@ -146,7 +146,7 @@ export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thre extends Pick< +interface ThreadProps extends Pick< GridListProps, - 'items' | 'children' | 'focusOnEntry' | 'aria-label' | 'aria-labelledby' | 'className' + 'items' | 'children' | 'UNSTABLE_focusonEntry' | 'aria-label' | 'aria-labelledby' | 'className' > {} -export function ThreadList(props: ThreadListProps) { +export function Thread(props: ThreadProps) { let { items, children, className, - focusOnEntry, + UNSTABLE_focusonEntry, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } = props; - let {setGridListFocused, setIsNearBottom, setScrollElement} = useContext(InternalThreadContext); + let {setGridListFocused, setIsNearBottom, setScrollElement} = useContext(InternalChatContext); let isNearBottomRef = useRef(true); let gridListRef = useRef(null); @@ -252,7 +252,7 @@ export function ThreadList(props: ThreadListProps) { disallowTypeAhead onScroll={handleScroll} keyboardNavigationBehavior="tab" - focusOnEntry={focusOnEntry} + UNSTABLE_focusonEntry={UNSTABLE_focusonEntry} items={items} aria-label={ariaLabel} aria-labelledby={ariaLabelledby} @@ -295,7 +295,7 @@ interface ThreadItemProps extends Pick = { - component: Thread, +const meta: Meta = { + component: Chat, parameters: { layout: 'centered' }, tags: ['autodocs'], - title: 'S2-AI/Thread', + title: 'S2-AI/Chat', decorators: [ Story => (
@@ -152,7 +152,7 @@ function CardMessage({ ); } -export function StreamingThread() { +export function StreamingChat() { let [messages, setMessages] = useState( initialResponses as StreamingMessage[] ); @@ -362,7 +362,7 @@ export function StreamingThread() { gap: 32, height: '100%' })}> -
- ); }} - + - + ); } // Ignore this story, just here for local testing -export function VirtualizedThread() { +export function VirtualizedChat() { let [messages, setMessages] = useState(initialResponses); let nextId = useRef(initialResponses.length); let lastMessage = messages.at(-1); @@ -525,7 +525,7 @@ export function VirtualizedThread() { { ]; let {container} = await render( - - + + {(item: Message) => {item.text}} - - + + ); let gridlist = container.querySelector('[role=grid]') as HTMLElement; diff --git a/packages/@react-spectrum/s2-ai/test/Thread.test.tsx b/packages/@react-spectrum/s2-ai/test/Chat.test.tsx similarity index 93% rename from packages/@react-spectrum/s2-ai/test/Thread.test.tsx rename to packages/@react-spectrum/s2-ai/test/Chat.test.tsx index aa265b7fb63..0ca5544dc4a 100644 --- a/packages/@react-spectrum/s2-ai/test/Thread.test.tsx +++ b/packages/@react-spectrum/s2-ai/test/Chat.test.tsx @@ -15,8 +15,8 @@ jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {Button, Input, TextField} from 'react-aria-components'; +import {Chat, Thread, ThreadItem, ThreadScrollButton} from '../src/Chat'; import React from 'react'; -import {Thread, ThreadItem, ThreadList, ThreadScrollButton} from '../src/Thread'; import userEvent from '@testing-library/user-event'; interface Message { @@ -27,33 +27,33 @@ interface Message { function TestThread({ messages, - focusOnEntry + UNSTABLE_focusonEntry }: { messages: Message[]; - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusonEntry?: 'first' | 'last'; }) { return ( - + - + {(item: Message) => ( {item.text} )} - + - + ); } let mockAnnounce = announce as jest.MockedFunction; -describe('Thread', () => { +describe('Chat', () => { let user; beforeAll(() => { @@ -245,10 +245,10 @@ describe('Thread', () => { }); describe('focus behavior', () => { - it('focuses the first item in the list when tabbing in if focusOnEntry="first"', async () => { + it('focuses the first item in the list when tabbing in if UNSTABLE_focusonEntry="first"', async () => { let {getByRole} = render( { expect(document.activeElement).toBe(rows[0]); }); - it('focuses the last item in the list when tabbing in if focusOnEntry="last"', async () => { + it('focuses the last item in the list when tabbing in if UNSTABLE_focusonEntry="last"', async () => { let {getByRole} = render( * * @private */ - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusonEntry?: 'first' | 'last'; } export const GridListContext = @@ -242,9 +242,14 @@ interface GridListInnerProps { function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { [props, ref] = useContextProps(props, ref, SelectableCollectionContext); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let {shouldUseVirtualFocus, filter, disallowTypeAhead, focusOnEntry, ...DOMCollectionProps} = - props; + let { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shouldUseVirtualFocus, + filter, + disallowTypeAhead, + UNSTABLE_focusonEntry, + ...DOMCollectionProps + } = props; let { dragAndDropHooks, keyboardNavigationBehavior = 'arrow', @@ -303,7 +308,7 @@ function GridListInner({props, collection, gridListRef: ref}: GridListInnerPr isVirtualized, shouldSelectOnPressUp: props.shouldSelectOnPressUp, disallowTypeAhead, - focusOnEntry + UNSTABLE_focusonEntry }, filteredState, ref diff --git a/packages/react-aria/src/gridlist/useGridList.ts b/packages/react-aria/src/gridlist/useGridList.ts index 7e37629560f..814ca34ac1c 100644 --- a/packages/react-aria/src/gridlist/useGridList.ts +++ b/packages/react-aria/src/gridlist/useGridList.ts @@ -112,7 +112,7 @@ export interface AriaGridListOptions extends Omit, 'chil * * @private */ - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusonEntry?: 'first' | 'last'; } export interface GridListAria { @@ -164,7 +164,7 @@ export function useGridList( disallowTypeAhead, autoFocus: props.autoFocus, escapeKeyBehavior, - focusOnEntry: props.focusOnEntry + UNSTABLE_focusonEntry: props.UNSTABLE_focusonEntry }); let id = useId(props.id); diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index a834398112d..95526294173 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -132,7 +132,7 @@ export interface AriaSelectableCollectionOptions { * * @private */ - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusonEntry?: 'first' | 'last'; } export interface SelectableCollectionAria { @@ -162,7 +162,7 @@ export function useSelectableCollection( // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, linkBehavior = 'action', - focusOnEntry + UNSTABLE_focusonEntry } = options; let {direction} = useLocale(); let router = useRouter(); @@ -291,7 +291,7 @@ export function useSelectableCollection( if (manager.focusedKey === null && e.shiftKey) { return; } - // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) + e.preventDefault(); let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(firstKey); @@ -431,12 +431,14 @@ export function useSelectableCollection( } }; - // we need the "virtual" modality case checks here because shift tabbing from the prompt field's asset card back into the + // we need the "virtual" modality case checks here because shift tabbing from the prompt field's attachment card back into the // thread is a virtual focus event (the tab handler in onKeyDown focuses the ref of the AttachementList aka TagGroup via a focus() call, hence the virtual modality) - if (focusOnEntry && (modality === 'keyboard' || modality === 'virtual')) { + if (UNSTABLE_focusonEntry && (modality === 'keyboard' || modality === 'virtual')) { // always go to the first item in the Thread when tabbing forwards/backwards into the collection // since it is probably more important to the user to see the new prompt reply rather than go to the last focused key - navigateToKey(focusOnEntry === 'first' ? delegate.getFirstKey?.() : delegate.getLastKey?.()); + navigateToKey( + UNSTABLE_focusonEntry === 'first' ? delegate.getFirstKey?.() : delegate.getLastKey?.() + ); } else if (manager.focusedKey == null) { // If the user hasn't yet interacted with the collection, there will be no focusedKey set. // Attempt to detect whether the user is tabbing forward or backward into the collection @@ -465,7 +467,7 @@ export function useSelectableCollection( focusWithoutScrolling(element); } - if (modality === 'keyboard' || modality === 'virtual') { + if (modality === 'keyboard' || (UNSTABLE_focusonEntry && modality === 'virtual')) { scrollIntoViewport(element, {containingElement: ref.current}); } } From 9bdeb747bd51ada36ed622a67897e5d74c223b07 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 11 Jun 2026 11:22:41 -0700 Subject: [PATCH 4/8] add transition to button, intl, and announce new item if inside the chat but outside the prompt field --- .../@react-spectrum/s2-ai/intl/ar-AE.json | 6 + .../@react-spectrum/s2-ai/intl/en-US.json | 1 + packages/@react-spectrum/s2-ai/src/Chat.tsx | 142 ++++++++++-------- 3 files changed, 89 insertions(+), 60 deletions(-) create mode 100644 packages/@react-spectrum/s2-ai/intl/ar-AE.json diff --git a/packages/@react-spectrum/s2-ai/intl/ar-AE.json b/packages/@react-spectrum/s2-ai/intl/ar-AE.json new file mode 100644 index 00000000000..adc6cbede25 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/intl/ar-AE.json @@ -0,0 +1,6 @@ +{ + "chat.newMessage": "New message", + "messagefeedback.thumbDown": "Bad response", + "messagefeedback.thumbUp": "Good response", + "responsestatus.loading": "Loading" +} diff --git a/packages/@react-spectrum/s2-ai/intl/en-US.json b/packages/@react-spectrum/s2-ai/intl/en-US.json index 8ac525f626b..adc6cbede25 100644 --- a/packages/@react-spectrum/s2-ai/intl/en-US.json +++ b/packages/@react-spectrum/s2-ai/intl/en-US.json @@ -1,4 +1,5 @@ { + "chat.newMessage": "New message", "messagefeedback.thumbDown": "Bad response", "messagefeedback.thumbUp": "Good response", "responsestatus.loading": "Loading" diff --git a/packages/@react-spectrum/s2-ai/src/Chat.tsx b/packages/@react-spectrum/s2-ai/src/Chat.tsx index e990013fdd4..c15669f48f2 100644 --- a/packages/@react-spectrum/s2-ai/src/Chat.tsx +++ b/packages/@react-spectrum/s2-ai/src/Chat.tsx @@ -16,6 +16,7 @@ import { createContext, forwardRef, ReactNode, + RefObject, useCallback, useContext, useEffect, @@ -31,21 +32,43 @@ import { GridListItemProps, GridListProps } from 'react-aria-components/GridList'; -import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {TextFieldContext} from 'react-aria-components/TextField'; import {useDOMRef} from './useDOMRef'; +import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; +import {useFocusWithin} from 'react-aria/useFocusWithin'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; + +const scrollButtonWrapper = style({ + opacity: { + isEntering: 0, + isExiting: 0 + }, + translateY: { + isEntering: 4, + isExiting: 4 + }, + transition: '[opacity, translate]', + transitionDuration: 200, + transitionTimingFunction: { + isExiting: 'in' + }, + pointerEvents: { + isExiting: 'none' + } +}); interface InternalChatContextValue { announceItem: (text: string) => void; - setGridListFocused: (isFocused: boolean) => void; setIsNearBottom: (isNear: boolean) => void; setScrollElement: (element: HTMLElement | null) => void; } const InternalChatContext = createContext({ announceItem: text => announce(text, 'polite'), - setGridListFocused: () => {}, setIsNearBottom: () => {}, setScrollElement: () => {} }); @@ -67,18 +90,17 @@ interface ChatProps { children?: ReactNode; } -// TODO: tabbing is a bit broken as well since we hit the child elements of the gridlist rows in opposite order... This seems to be due to the -// tabIndex = 0 of the ToggleButtons in the ToggleButtonGroup export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( props: ChatProps, ref: DOMRef ) { let {children, className, style} = props; let domRef = useDOMRef(ref); - let isGridListFocusedRef = useRef(false); let isFieldFocusedRef = useRef(false); + let isChatFocusWithinRef = useRef(false); let hasNewMessagesRef = useRef(false); let timeout = useRef | null>(null); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let scrollRef = useRef(null); let scrollToBottom = useCallback(() => { @@ -103,37 +125,42 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( // 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 - let announceItem = useCallback((text: string) => { - if (isGridListFocusedRef.current) { - // TODO: ideally announce number of new messages, but only count system messages? maybe threaditem needs - // to have a "type" prop - if (!hasNewMessagesRef.current) { - hasNewMessagesRef.current = true; - announce('New message', 'polite'); - // TODO: arbirary amount of time to wait before announcing new message, maybe we don't clear until - // we detect they scroll down? Or maybe when we do the message count we do it after a certain number of messages? - // or maybe this is fine - timeout.current = setTimeout(() => { - hasNewMessagesRef.current = false; - timeout.current = null; - }, 5000); + let announceItem = useCallback( + (text: string) => { + if (isFieldFocusedRef.current) { + announce(text, 'polite'); + return; } - return; - } - - if (isFieldFocusedRef.current) { - announce(text, 'polite'); - } - }, []); - let setGridListFocused = useCallback((isFocused: boolean) => { - isGridListFocusedRef.current = isFocused; - }, []); + if (isChatFocusWithinRef.current) { + // TODO: ideally announce number of new messages, but only count system messages? maybe threaditem needs + // to have a "type" prop + if (!hasNewMessagesRef.current) { + hasNewMessagesRef.current = true; + announce(stringFormatter.format('chat.newMessage'), 'polite'); + // TODO: arbirary amount of time to wait before announcing new message, maybe we don't clear until + // we detect they scroll down? Or maybe when we do the message count we do it after a certain number of messages? + // or maybe this is fine + timeout.current = setTimeout(() => { + hasNewMessagesRef.current = false; + timeout.current = null; + }, 5000); + } + } + }, + [stringFormatter] + ); let setScrollElement = useCallback((el: HTMLElement | null) => { scrollRef.current = el; }, []); + let {focusWithinProps} = useFocusWithin({ + onFocusWithinChange: isFocused => { + isChatFocusWithinRef.current = isFocused; + } + }); + useEffect(() => { return () => { if (timeout.current !== null) { @@ -145,10 +172,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( return ( -
+
{children}
@@ -187,7 +211,7 @@ export function Thread(props: ThreadProps) { 'aria-labelledby': ariaLabelledby } = props; - let {setGridListFocused, setIsNearBottom, setScrollElement} = useContext(InternalChatContext); + let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext); let isNearBottomRef = useRef(true); let gridListRef = useRef(null); @@ -199,28 +223,6 @@ export function Thread(props: ThreadProps) { [setScrollElement] ); - // TODO: gridlist doesn't have onFocus/onBlur - useEffect(() => { - let el = gridListRef.current; - if (!el) { - return; - } - - let onFocusIn = () => setGridListFocused(true); - let onFocusOut = (e: FocusEvent) => { - if (!nodeContains(el, e.relatedTarget as Node)) { - setGridListFocused(false); - } - }; - - el.addEventListener('focusin', onFocusIn); - el.addEventListener('focusout', onFocusOut); - return () => { - el.removeEventListener('focusin', onFocusIn); - el.removeEventListener('focusout', onFocusOut); - }; - }, [setGridListFocused]); - let handleScroll = useCallback(() => { let el = gridListRef.current; if (!el) { @@ -272,19 +274,39 @@ interface ThreadScrollButtonProps { // and ditch the wrapper? export function ThreadScrollButton({children}: ThreadScrollButtonProps) { let {isNearBottom, scrollToBottom} = useContext(ThreadScrollButtonContext); + let ref = useRef(null); + let isVisible = !isNearBottom; + let isExiting = useExitAnimation(ref, isVisible); - if (isNearBottom) { + if (!isVisible && !isExiting) { return null; } return ( - {children} + + {children} + ); } +interface ThreadScrollButtonInnerProps { + domRef: RefObject; + isExiting: boolean; + children?: ReactNode; +} + +function ThreadScrollButtonInner({domRef, isExiting, children}: ThreadScrollButtonInnerProps) { + let isEntering = useEnterAnimation(domRef); + return ( +
+ {children} +
+ ); +} + // TODO: update the className type to reflect a thread specific classname once we finalize API interface ThreadItemProps extends Pick { /** Whether or not the item's content is currently being streamed in. */ From ed9c30bdede645160007f07449515bdaa9dea6d6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 11 Jun 2026 11:31:42 -0700 Subject: [PATCH 5/8] removing extranous story --- .../ai/stories/Thread.stories.tsx | 813 ------------------ 1 file changed, 813 deletions(-) delete mode 100644 packages/@react-spectrum/ai/stories/Thread.stories.tsx diff --git a/packages/@react-spectrum/ai/stories/Thread.stories.tsx b/packages/@react-spectrum/ai/stories/Thread.stories.tsx deleted file mode 100644 index 9416cc47df7..00000000000 --- a/packages/@react-spectrum/ai/stories/Thread.stories.tsx +++ /dev/null @@ -1,813 +0,0 @@ -/* - * 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 {ActionButton} from '@react-spectrum/s2/ActionButton'; -import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; -import {AssetCard, Card, CardPreview} from '@react-spectrum/s2/Card'; -import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Button} from '@react-spectrum/s2/Button'; -import { - ButtonContext, - GridList, - Group, - isFileDropItem, - Label, - Tag, - TagGroup, - TagList, - TextArea, - TextField, - useDrop -} from 'react-aria-components'; -import {Chat, Thread, ThreadItem, ThreadScrollButton} from '../src/Chat'; -import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; -import {CloseButton} from '@react-spectrum/s2/CloseButton'; -import {Content} from '@react-spectrum/s2/Content'; -import {Image} from '@react-spectrum/s2/Image'; -import {Link} from '@react-spectrum/s2/Link'; -import {ListLayout} from 'react-stately/useVirtualizerState'; -import {MenuItem} from '@react-spectrum/s2/Menu'; -import {MessageFeedback} from '../src/MessageFeedback'; -import {MessageSource, SourceList, SourceListItem} from '../src/MessageSource'; -import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; -import type {Meta} from '@storybook/react'; -import Plus from '@react-spectrum/s2/icons/Add'; -import {ReactNode, useRef, useState} from 'react'; -import {ResponseStatus, ResponseStatusPanel, ResponseStatusTitle} from '../src/ResponseStatus'; -import Send from '@react-spectrum/s2/icons/ArrowUpSend'; -import {Text} from '@react-spectrum/s2/Text'; -import {UserMessage} from '../src/UserMessage'; -import {Virtualizer} from 'react-aria-components/Virtualizer'; - -const meta: Meta = { - component: Chat, - parameters: { - layout: 'centered' - }, - tags: ['autodocs'], - title: 'AI/Chat', - decorators: [ - Story => ( -
- -
- ) - ] -}; - -export default meta; - -let dummyResponses = [ - "Sure! Here's a summary of the key points based on the assets you shared. The main themes revolve around brand consistency, audience engagement, and clear calls to action across all touchpoints.", - 'Great question. Based on the context provided, I recommend focusing on the narrative arc first, then layering in supporting visuals and data to reinforce the core message.', - "I've analyzed the content and identified three main opportunities: improving visual hierarchy, strengthening the headline, and adding a clearer value proposition in the opening section." -]; - -type Message = - | {id: number; type: 'user' | 'system'; content: string} - | {id: number; type: 'status'; status: 'pending' | 'complete'}; - -let initialResponses = [ - {id: 0, type: 'user', content: 'prompt 1'}, - {id: 1, type: 'system', content: dummyResponses[0]}, - {id: 2, type: 'user', content: 'prompt 2'}, - {id: 3, type: 'system', content: dummyResponses[1]}, - {id: 4, type: 'user', content: 'prompt 3'}, - {id: 5, type: 'system', content: dummyResponses[2]}, - {id: 6, type: 'user', content: 'prompt 4'}, - {id: 7, type: 'system', content: dummyResponses[0]}, - {id: 8, type: 'user', content: 'prompt 5'}, - {id: 9, type: 'system', content: dummyResponses[1]}, - {id: 10, type: 'user', content: 'prompt 6'}, - {id: 11, type: 'system', content: dummyResponses[2]} -] as Message[]; - -type StreamingMessage = - | {id: number; type: 'user'; content: string} - | {id: number; type: 'system'; content: string; isStreaming?: boolean; sources?: string[]} - | { - id: number; - type: 'status'; - label: string; - isStreaming: boolean; - details: string; - } - | {id: number; type: 'card'; title: string; description: string; imageUrl: string} - | {id: number; type: 'suggestions'; title: string; suggestions: string[]}; - -let MOCK_SOURCES = [ - 'Hilton brand email — Q1 campaign 2026', - 'Market research — hospitality trends 2025', - 'User research — loyalty programme survey' -]; - -let MOCK_SUGGESTIONS = [ - 'Suggest a presentation structure', - 'What other assets might be relevant?', - 'Summarize the key themes' -]; - -let MOCK_CARD = { - title: 'Desert Sunset', - description: 'PNG • 2/3/2024', - imageUrl: - 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' -}; - -function CardMessage({ - title, - description, - imageUrl -}: { - title: string; - description: string; - imageUrl: string; -}) { - return ( - - - - - - - {title} - - Edit - Share - Delete - - {description} - - - - ); -} - -export function StreamingChat() { - let [messages, setMessages] = useState( - initialResponses as StreamingMessage[] - ); - let nextId = useRef(initialResponses.length); - let lastMessage = messages.at(-1); - let isDisabled = - (lastMessage?.type === 'status' && lastMessage.isStreaming) || - (lastMessage?.type === 'system' && lastMessage.isStreaming); - - function handleSend(text: string) { - if (!text.trim()) { - return; - } - - // user message added first so its announcement plays before - setMessages(prev => [...prev, {id: nextId.current++, type: 'user', content: text}]); - - function addTool(label: string, replaceStatus = false) { - setMessages(prev => - replaceStatus - ? [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - : [ - ...prev, - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - ); - } - - function completeTool(details: string) { - setMessages(prev => - prev.map(m => - m.type === 'status' && m.isStreaming ? {...m, isStreaming: false, details} : m - ) - ); - } - - function streamText(content: string, sources?: string[]) { - setMessages(prev => [ - ...prev, - {id: nextId.current++, type: 'system', content: '', isStreaming: true} - ]); - let tokens = content.split(' '); - let accumulated = ''; - tokens.forEach((token, i) => { - setTimeout(() => { - 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, - ...(isLastToken && sources ? {sources} : {}) - } - : m - ) - ); - }, i * 80); - }); - } - - // TODO: these durations are quite generous in order to accomodate for announcements, but realistically it might be - // faster and thus the announcements will get cut off even with polite... - // first batch, does tool calls with text response - let timestamp = 0; - let toolCallDuration = 4000; - // Status added after short delay so user message announcement plays first - setTimeout( - () => { - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]); - }, - (timestamp += 1000) - ); - setTimeout(() => addTool('Thinking', true), (timestamp += 1000)); - setTimeout( - () => - completeTool( - 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' - ), - (timestamp += toolCallDuration) - ); - setTimeout(() => addTool('Loading tool'), (timestamp += 1000)); - setTimeout( - () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), - (timestamp += toolCallDuration) - ); - setTimeout(() => addTool('Searching'), (timestamp += 1000)); - setTimeout( - () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), - (timestamp += toolCallDuration) - ); - setTimeout( - () => - streamText( - 'I found some relevant assets that match your request. Let me pull up the details.' - ), - (timestamp += 1000) - ); - - // then does searching, streaming more text, returning a card and sources - setTimeout(() => addTool('Searching'), (timestamp += 4000)); - setTimeout( - () => - completeTool('Identified additional brand materials related to the presentation context.'), - (timestamp += toolCallDuration) - ); - setTimeout(() => addTool('Querying database'), (timestamp += 1000)); - setTimeout( - () => - completeTool( - 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' - ), - (timestamp += toolCallDuration) - ); - setTimeout( - () => - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]), - (timestamp += 500) - ); - setTimeout( - () => - setMessages(prev => [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label: 'Response generated', - isStreaming: false, - details: - 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' - } - ]), - (timestamp += 2000) - ); - setTimeout( - () => - streamText( - 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + - 'layering in supporting visuals and data to reinforce the core message. The main themes ' + - 'revolve around brand consistency, audience engagement, and clear calls to action.', - MOCK_SOURCES - ), - (timestamp += 1000) - ); - - let streamEndTimestamp = timestamp + 8000; - setTimeout(() => { - setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); - }, streamEndTimestamp); - setTimeout(() => { - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'suggestions', - title: 'Suggested follow-ups', - suggestions: MOCK_SUGGESTIONS - } - ]); - }, streamEndTimestamp + 1000); - } - - 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') { - let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; - let title = msg.isStreaming ? `${msg.label}…` : msg.label; - // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? - // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we - // probably want to make that on a case by case basis - // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) - return ( - - - {title} - - {msg.details && ( -

{msg.details}

- )} -
-
-
- ); - } - 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 && } -
- ); - }} -
-
- -
-
- ); -} - -// Ignore this story, just here for local testing -export function VirtualizedChat() { - let [messages, setMessages] = useState(initialResponses); - let nextId = useRef(initialResponses.length); - let lastMessage = messages.at(-1); - let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; - function handleSend(text: string) { - if (!text.trim()) { - return; - } - setMessages(prev => [ - ...prev, - {id: nextId.current++, type: 'user', content: text}, - {id: nextId.current++, type: 'status', status: 'pending'} - ]); - setTimeout(() => { - let response = dummyResponses[Math.floor(Math.random() * dummyResponses.length)]; - setMessages(prev => [ - ...prev.slice(0, -1), - {id: nextId.current++, type: 'system', content: response} - ]); - }, 1500); - } - - return ( -
- - - {msg => { - if (msg.type === 'user') { - return ( - - {msg.content} - - ); - } - if (msg.type === 'status') { - let isPending = msg.status === 'pending'; - let message = isPending ? 'Generating response' : 'Response generated'; - - return ( - - - {message} - - - ); - } - return ( - -
-

{msg.content}

-
- -
- ); - }} -
-
- -
- ); -} - -// TODO: all of the below was copied from rsp-prototypes, just filler for now -// some modifications for streaming and what not -function PromptField({ - onSend, - isDisabled -}: { - onSend?: (text: string) => void; - isDisabled?: boolean; -}) { - let [text, setText] = useState(''); - let [attachments, setAttachments] = useState([ - { - image: 'https://react-spectrum.adobe.com/preview.c3b340d3.png', - title: 'Hilton assets', - description: '2026' - } - ]); - - // Not using RAC DropZone because it adds its own focusable button, - // and we want to avoid an extra tab stop by attaching to the input. - // TODO: support clipboard too (without messing up pasting text) - let inputRef = useRef(null); - let {dropProps, dropButtonProps, isDropTarget} = useDrop({ - ref: inputRef, - hasDropButton: true, - async onDrop(e) { - let files = await Promise.all( - e.items.filter(isFileDropItem).map(async item => ({ - image: item.type.startsWith('image/') ? URL.createObjectURL(await item.getFile()) : '', - title: item.name, - description: item.type - })) - ); - setAttachments(attachments => [...attachments, ...files]); - } - }); - - return ( -
- - style({ - ...focusRing(), - padding: 16, - boxShadow: 'emphasized', - backgroundColor: { - default: 'elevated', - isDropTarget: 'blue-200' - }, - borderRadius: 'lg', - borderWidth: 2, - borderStyle: 'solid', - borderColor: { - default: 'transparent', - isFocusWithin: 'gray-900', - isDropTarget: 'blue-800' - } - })({...renderProps, isDropTarget}) - }> - - {attachments.map((attachment, i) => ( - { - setAttachments(attachments.slice(0, i).concat(attachments.slice(i + 1))); - }} - /> - ))} - - setText(value)} slot="prompt"> - -