Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 26 additions & 3 deletions packages/@react-spectrum/ai/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,24 @@ export {
PromptToken,
PromptFieldVoiceButton
} from '../src/PromptField';
export {ResponseStatus, ResponseStatusTitle, ResponseStatusPanel} from '../src/ResponseStatus';
export {Chat, Thread, ThreadItem, ThreadScrollButton, PromptFocusContext} from '../src/Chat';
export {
ExecutionTrace,
ExecutionTraceItem,
ResponseStatus,
ResponseStatusTitle,
ResponseStatusPanel
} from '../src/ResponseStatus';
export {
Chat,
Thread,
ThreadItem,
ThreadLoadMoreItem,
ThreadScrollButton,
PromptFocusContext
} from '../src/Chat';
export {TokenFieldValue} from 'react-aria-components/TokenField';
export {UserMessage} from '../src/UserMessage';
export {PixelLoader} from '../src/loader/react';

export type {AttachmentProps, AttachmentListProps} from '../src/AttachmentList';
export type {
Expand All @@ -39,10 +53,19 @@ export type {MessageFeedbackProps} from '../src/MessageFeedback';
export type {MessageSourceProps, SourceListProps, SourceListItemProps} from '../src/MessageSource';
export type {MessageSuggestionProps, MessageSuggestionListProps} from '../src/MessageSuggestion';
export type {
ExecutionTraceProps,
ExecutionTraceItemProps,
ResponseStatusProps,
ResponseStatusTitleProps,
ResponseStatusPanelProps
} from '../src/ResponseStatus';
export type {ChatProps, ThreadProps, ThreadItemProps, ThreadScrollButtonProps} from '../src/Chat';
export type {
ChatProps,
ThreadProps,
ThreadItemProps,
ThreadLoadMoreItemProps,
ThreadScrollButtonProps
} from '../src/Chat';
export type {TokenFieldValueOptions} from 'react-aria-components/TokenField';
export type {UserMessageProps} from '../src/UserMessage';
export type {PixelLoaderProps} from '../src/loader/react';
3 changes: 3 additions & 0 deletions packages/@react-spectrum/ai/exports/loader.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export {PixelLoader} from '../src/loader/react';
export * from '../src/loader/data';

export type {PixelLoaderProps} from '../src/loader/react';
154 changes: 115 additions & 39 deletions packages/@react-spectrum/ai/src/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,49 @@

import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer';
import {ButtonContext} from 'react-aria-components/Button';
import {
CollectionRendererContext,
createLeafComponent
} from 'react-aria-components/CollectionBuilder';
import {
createContext,
ForwardedRef,
forwardRef,
ReactNode,
RefObject,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots';
import {DOMRef, forwardRefType} from '@react-types/shared';
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'};
import {
GridList,
GridListItem,
GridListItemProps,
GridListLoadMoreItemProps,
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 {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 {useLoadMoreSentinel} from 'react-aria/private/utils/useLoadMoreSentinel';
import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter';
import {useRenderProps} from 'react-aria-components/useRenderProps';
import {Virtualizer} from 'react-aria-components/Virtualizer';

const scrollButtonWrapper = style({
opacity: {
Expand Down Expand Up @@ -137,7 +152,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 @@ -217,28 +232,35 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat(

export interface ThreadProps<T extends object> extends Pick<
GridListProps<T>,
'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby'
'items' | 'children' | 'aria-label' | 'aria-labelledby'
> {
/**
* 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;
}

export function Thread<T extends object>(props: ThreadProps<T>) {
let {
items,
children,
styles,
UNSTABLE_focusOnEntry,
scrollEndThreshold = 100,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby
} = props;

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 @@ -253,45 +275,42 @@ 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 = el.scrollTop >= el.scrollHeight - el.clientHeight - scrollEndThreshold;
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]);

return (
<GridList
ref={callbackRef}
disallowTypeAhead
onScroll={handleScroll}
keyboardNavigationBehavior="tab"
UNSTABLE_focusOnEntry={UNSTABLE_focusOnEntry}
items={items}
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',
minWidth: 0
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: 100,
padding: 4,
gap: 8,
anchorTo: 'end',
loaderSize: 48,
scrollEndThreshold
}}
className={styles}>
{children}
</GridList>
shouldObserveItemSize>
<GridList
ref={callbackRef}
disallowTypeAhead
onScroll={handleScroll}
keyboardNavigationBehavior="tab"
UNSTABLE_focusOnEntry="last"
items={items}
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>
);
}

Expand Down Expand Up @@ -401,3 +420,60 @@ export function ThreadItem(props: ThreadItemProps) {
</GridListItem>
);
}

export interface ThreadLoadMoreItemProps extends GridListLoadMoreItemProps {}

// TODO: Reuse GridListLoadMoreItem instead when Thread component moves into RAC.
// Re-implementing here so we can avoid passing 'direction' to the LoadMore item
export const ThreadLoadMoreItem = createLeafComponent(
LoaderNode,
function GridListLoadingIndicator(
props: GridListLoadMoreItemProps,
ref: ForwardedRef<HTMLDivElement>,
item: Node<object>
) {
let state = useContext(ListStateContext)!;
let direction: 'start' | 'end' | undefined = 'start';
let {isVirtualized} = useContext(CollectionRendererContext);
let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props;

let sentinelRef = useRef(null);
let memoedLoadMoreProps = useMemo(
() => ({onLoadMore, collection: state?.collection, scrollOffset, direction}),
[onLoadMore, scrollOffset, state?.collection, direction]
);
useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef);

let renderProps = useRenderProps({
...otherProps,
id: undefined,
children: item.rendered,
defaultClassName: 'react-aria-GridListLoadingIndicator',
values: undefined
});
// For now don't include aria-posinset and aria-setsize on loader since they aren't keyboard focusable
// Arguably shouldn't include them ever since it might be confusing to the user to include the loaders as part of the
// item count

return (
<>
{/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */}
{/* @ts-ignore - compatibility with React < 19 */}
<div style={{position: 'relative', width: 0, height: 0}} inert={inertValue(true)}>
<div
data-testid="loadMoreSentinel"
ref={sentinelRef}
style={{position: 'absolute', height: 1, width: 1}}
/>
</div>
{isLoading && renderProps.children && (
<div {...renderProps} {...filterDOMProps(props, {global: true})} role="row" ref={ref}>
<div aria-colindex={isVirtualized ? 1 : undefined} role="gridcell">
{renderProps.children}
</div>
</div>
)}
</>
);
}
);
Loading
Loading