Skip to content
Draft
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
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 @@ -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,
Expand All @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<InternalChatContextValue>({
export const InternalChatContext = createContext<InternalChatContextValue>({
announceItem: text => announce(text, 'polite'),
setIsNearBottom: () => {},
setScrollElement: () => {}
setScrollElement: () => {},
promptFieldSize: 'M',
setPromptFieldSize: () => {}
});

interface ThreadScrollButtonContextValue {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -209,7 +216,10 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat(
return (
<Provider
values={[
[InternalChatContext, {announceItem, setIsNearBottom, setScrollElement}],
[
InternalChatContext,
{announceItem, setIsNearBottom, setScrollElement, promptFieldSize, setPromptFieldSize}
],
[
ThreadScrollButtonContext,
{
Expand All @@ -227,7 +237,23 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat(
}
]
]}>
<div ref={domRef} className={styles} {...focusWithinProps}>
<div
ref={domRef}
className={mergeStyles(
style({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexGrow: 1,
paddingX: 16,
boxSizing: 'border-box',
minWidth: 0,
containerType: 'size',
height: 'full'
}),
styles
)}
{...focusWithinProps}>
{children}
</div>
</Provider>
Expand Down Expand Up @@ -265,7 +291,7 @@ export function Thread<T extends object>(props: ThreadProps<T>) {
'aria-labelledby': ariaLabelledby
} = props;

let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext);
let {setIsNearBottom, setScrollElement, promptFieldSize} = useContext(InternalChatContext);
let isNearBottomRef = useRef(true);
let gridListRef = useRef<HTMLDivElement | null>(null);
let callbackRef = useCallback(
Expand All @@ -288,39 +314,89 @@ export function Thread<T extends object>(props: ThreadProps<T>) {
}, [setIsNearBottom, scrollEndThreshold]);

return (
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: 100,
padding: 4,
gap: 8,
anchorTo: 'end',
loaderSize: 48,
scrollEndThreshold
}}
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={
{
<div
className={mergeStyles(
style({
position: 'relative',
flexGrow: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
minWidth: 0
}),
styles
)}>
<div
className={mergeStyles(
style({
position: 'relative',
flexGrow: 1,
overflow: 'hidden',
display: 'flex',
boxSizing: 'border-box',
minWidth: 0,
scrollbarGutter: 'stable'
} as CSSProperties
}
className={styles}>
{children}
</GridList>
</Virtualizer>
flexDirection: 'column',
minWidth: 0
}),
styles
)}>
<div
className={style({
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1
})}>
<ThreadScrollButton>
<ActionButton slot="scroll" aria-label="Scroll to bottom">
<ChevronDown />
</ActionButton>
</ThreadScrollButton>
</div>
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: 100,
padding: promptFieldSize === 'S' ? 16 : 24,
gap: 16,
anchorTo: 'end',
loaderSize: 48,
scrollEndThreshold
}}
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?
className={
scrollFade({y: 32}) +
' ' +
style({
display: 'flex',
boxSizing: 'border-box',
minWidth: 0,
scrollbarGutter: 'stable',
flexGrow: 1,
overflowX: 'hidden',
overflowY: 'auto',
scrollPadding: {
default: 24,
promptFieldSize: {
S: 16
}
}
})({promptFieldSize})
}>
{children}
</GridList>
</Virtualizer>
</div>
</div>
);
}

Expand Down
6 changes: 5 additions & 1 deletion packages/@react-spectrum/ai/src/PromptField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Loading