Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8a74362
add resize observer to virtualized items
yihuiliao Jun 18, 2026
dd0bdd7
update chat stories
yihuiliao Jun 18, 2026
a196996
update chat component with reverse virtualizer
yihuiliao Jun 18, 2026
737787a
support reverse virtualization in list layout
yihuiliao Jun 18, 2026
a372b0a
change isReversed to anchorTo end
yihuiliao Jun 18, 2026
f753e89
consolidate props/code
yihuiliao Jun 18, 2026
532dcb8
fix layout from calling setVisibleRect
yihuiliao Jun 18, 2026
9340a68
format
yihuiliao Jun 18, 2026
754fe2f
reverse items in Thread, update reverseBuildCollection
yihuiliao Jun 18, 2026
dad792a
add scroll anchor to chat
yihuiliao Jun 23, 2026
b4892c8
remove scroll anchoring logic from list layout
yihuiliao Jun 23, 2026
d4cf4d1
update chat stories
yihuiliao Jun 23, 2026
01aa9a5
cleanup from removing scroll anchoring logic from list layout
yihuiliao Jun 23, 2026
112ef5b
listlayout cleanup
yihuiliao Jun 23, 2026
1d36bc1
add scroll anchoring
yihuiliao Jun 24, 2026
d2a3862
separate out scroll context
yihuiliao Jun 24, 2026
4dca942
fix anchor reserve when submitting key for scroll anchoring
yihuiliao Jun 24, 2026
00ac9c2
fix tests
yihuiliao Jun 24, 2026
949fbcc
remove key based scroll anchoring
yihuiliao Jun 24, 2026
ca42626
fix format
yihuiliao Jun 24, 2026
d265711
fix lint
yihuiliao Jun 24, 2026
826e459
Merge branch 'main' into reverse-virtualizer
yihuiliao Jun 24, 2026
37edc12
fix type issue
yihuiliao Jun 24, 2026
f4042f9
cleanup, fix non virtualized chat scrollToEnd button
yihuiliao Jun 24, 2026
a8066da
edit height of chat story
yihuiliao Jun 24, 2026
981e4cf
code cleanup
yihuiliao Jun 24, 2026
057e027
fix formatting
yihuiliao Jun 24, 2026
3a31111
hanle reverse in list layout
yihuiliao Jun 25, 2026
cb8d2b3
Merge branch 'main' into reverse-virtualizer
yihuiliao Jun 25, 2026
d0cda86
fix streaming
yihuiliao Jun 25, 2026
820d45d
add/cleanup comments
yihuiliao Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 66 additions & 24 deletions packages/@react-spectrum/ai/src/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
Expand All @@ -34,12 +35,14 @@ import {
} from 'react-aria-components/GridList';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {ListLayout} from 'react-stately/useVirtualizerState';
import {mergeStyles} from '@react-spectrum/s2/mergeStyles';
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';
import {Virtualizer} from 'react-aria-components/Virtualizer';

const scrollButtonWrapper = style({
opacity: {
Expand Down Expand Up @@ -131,7 +134,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat(
},
{once: true}
);
el.scrollTo({top: 0, behavior: 'smooth'});
el.scrollTo({top: el.scrollHeight - el.clientHeight, behavior: 'smooth'});
}, []);
let [isNearBottom, setIsNearBottom] = useState(true);

Expand Down Expand Up @@ -202,6 +205,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat(
);
});

// TODO: do we want UNSTABLE_focusOnEntry on ThreadProps or do we just want to set it on Thread for users so they don't have to do it themselves?
export interface ThreadProps<T extends object> extends Pick<
GridListProps<T>,
'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby'
Expand All @@ -210,6 +214,15 @@ export interface ThreadProps<T extends object> extends Pick<
* Spectrum-defined styles, returned by the `style()` macro.
*/
styles?: StyleString;
/**
* The maximum distance in px from the bottom of the content for the
* viewport to be considered "near the end". While near the end, appended content and streaming
* size changes will keep the viewport pinned to the latest output.
*
* @default 100
*/
scrollEndThreshold?: number;
anchorTo?: 'end';
}

export function Thread<T extends object>(props: ThreadProps<T>) {
Expand All @@ -218,14 +231,17 @@ export function Thread<T extends object>(props: ThreadProps<T>) {
children,
styles,
UNSTABLE_focusOnEntry,
anchorTo,
scrollEndThreshold = 100,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby
} = props;

let reversedItems = useMemo(() => (items != null ? [...items].reverse() : undefined), [items]);

let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext);
let isNearBottomRef = useRef(true);
let gridListRef = useRef<HTMLDivElement | null>(null);

let callbackRef = useCallback(
(el: HTMLDivElement | null) => {
gridListRef.current = el;
Expand All @@ -240,46 +256,71 @@ export function Thread<T extends object>(props: ThreadProps<T>) {
return;
}

// because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up
let nearBottom = el.scrollTop > -100;
let nearBottom =
anchorTo === 'end'
? el.scrollTop >= el.scrollHeight - el.clientHeight - scrollEndThreshold
: el.scrollTop > -100;
isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom);
}, [setIsNearBottom]);

useEffect(() => {
// scrolls to bottom on first render cuz we initialize isNearBottomRef to true,
// otherwise handles scrolling new prompts/etc into view unless you are scrolled up above
// 100px
if (isNearBottomRef.current) {
requestAnimationFrame(() => {
if (gridListRef.current) {
gridListRef.current.scrollTop = 0;
}
});
}
}, [items]);
}, [setIsNearBottom, scrollEndThreshold, anchorTo]);

return (
let gridList = (
<GridList
ref={callbackRef}
disallowTypeAhead
onScroll={handleScroll}
keyboardNavigationBehavior="tab"
UNSTABLE_focusOnEntry={UNSTABLE_focusOnEntry}
items={items}
UNSTABLE_focusOnEntry={UNSTABLE_focusOnEntry ?? 'first'}
items={reversedItems}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
// TODO: for now we enforce this, but to be configurable?
style={{
display: 'flex',
flexDirection: 'column-reverse',
boxSizing: 'border-box',
flexDirection: 'column-reverse',
minWidth: 0
}}
className={styles}>
{children}
</GridList>
);

if (anchorTo === 'end') {
return (
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: 100,
padding: 4,
gap: 8,
anchorTo: 'end'
}}
scrollEndThreshold={scrollEndThreshold}
shouldObserveItemSize>
<GridList
ref={callbackRef}
disallowTypeAhead
onScroll={handleScroll}
keyboardNavigationBehavior="tab"
UNSTABLE_focusOnEntry={UNSTABLE_focusOnEntry ?? 'first'}
items={reversedItems}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
// TODO: for now we enforce this, but to be configurable?
style={{
display: 'flex',
boxSizing: 'border-box',
minWidth: 0
}}
className={styles}>
{children}
</GridList>
</Virtualizer>
);
}

return gridList;
}

export interface ThreadScrollButtonProps {
Expand Down Expand Up @@ -328,7 +369,7 @@ const threadItemBase = style({
borderRadius: 'default'
});

export interface ThreadItemProps extends Pick<GridListItemProps, 'children' | 'textValue'> {
export interface ThreadItemProps extends Pick<GridListItemProps, 'children' | 'textValue' | 'id'> {
/**
* Spectrum-defined styles, returned by the `style()` macro.
*/
Expand All @@ -340,7 +381,7 @@ export interface ThreadItemProps extends Pick<GridListItemProps, 'children' | 't
}

export function ThreadItem(props: ThreadItemProps) {
let {styles, children, textValue = ' ', isStreaming, shouldAnnounceOnMount} = props;
let {styles, children, textValue = ' ', isStreaming, shouldAnnounceOnMount, id} = props;
let {announceItem} = useContext(InternalChatContext);

// TODO: using aria-live on the gridlist item was pretty chatty and the streaming causes the text announcement
Expand Down Expand Up @@ -369,6 +410,7 @@ export function ThreadItem(props: ThreadItemProps) {

return (
<GridListItem
id={id}
textValue={textValue}
className={renderProps => mergeStyles(threadItemBase({...renderProps}), styles)}>
{children}
Expand Down
Loading