-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: (WIP) Thread ai component #10045
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
LFDanLu
wants to merge
22
commits into
setup-ai-components-package
Choose a base branch
from
thread_ai_component
base: setup-ai-components-package
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
a6da5c1
base setup, transferring the stuff from the prototypes repo
LFDanLu 2bf1eda
add dynamic story
LFDanLu 6ef9558
more things to fix
LFDanLu 5b71dd6
explorations
LFDanLu 82ce13b
add scroll to bottom button and scroll when new messages are added if…
LFDanLu 24cfe69
use aria live to announce new items
LFDanLu a7970ed
tentative column reverse investigation and update stories to reflect …
LFDanLu 7327357
handle keyboard navigation when using column reverse
LFDanLu 4ad518f
fix pageup/page down looping in column reverse
LFDanLu 15dd14d
force shift tab and tab to go to newest card on first enty
LFDanLu 7a42782
make it so whenever you tab into the thread, you go to newest content…
LFDanLu b400f29
virtualized story and testing stuff
LFDanLu 9ffb393
announcement of new items only
LFDanLu a8c43d2
add streaming example
LFDanLu 40e12d0
make example more realistic and fine tune announcement behavior
LFDanLu 061e93e
add thinking response status
LFDanLu 4258d43
tried aria live on gridlist item
LFDanLu 5ad6744
only announce if in thread or in field
LFDanLu fa55afb
only announce "new message" if user is in the thread, fix focus loss …
LFDanLu 9b8b7de
forgot a new option doc
LFDanLu 8e13fde
move to more RAC like API
LFDanLu 3a21488
move to ai components repo
LFDanLu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,332 @@ | ||
| /* | ||
| * 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 {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; | ||
| import {ButtonContext} from 'react-aria-components/Button'; | ||
| import { | ||
| createContext, | ||
| forwardRef, | ||
| ReactNode, | ||
| useCallback, | ||
| useContext, | ||
| useEffect, | ||
| useRef, | ||
| useState | ||
| } from 'react'; | ||
| import type {CSSProperties} from 'react'; | ||
| import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; | ||
| import {DOMRef, forwardRefType} from '@react-types/shared'; | ||
| import { | ||
| GridList, | ||
| GridListItem, | ||
| GridListItemProps, | ||
| GridListProps | ||
| } from 'react-aria-components/GridList'; | ||
| import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; | ||
| import {TextFieldContext} from 'react-aria-components/TextField'; | ||
| import {useDOMRef} from './useDOMRef'; | ||
| import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; | ||
|
|
||
| interface InternalThreadContextValue { | ||
| announceItem: (text: string) => void; | ||
| setGridListFocused: (isFocused: boolean) => void; | ||
| setIsNearBottom: (isNear: boolean) => void; | ||
| setScrollElement: (element: HTMLElement | null) => void; | ||
| } | ||
|
|
||
| const InternalThreadContext = createContext<InternalThreadContextValue>({ | ||
| announceItem: text => announce(text, 'polite'), | ||
| setGridListFocused: () => {}, | ||
| setIsNearBottom: () => {}, | ||
| setScrollElement: () => {} | ||
| }); | ||
|
|
||
| interface ThreadScrollButtonContextValue { | ||
| isNearBottom: boolean; | ||
| scrollToBottom: () => void; | ||
| } | ||
|
|
||
| const ThreadScrollButtonContext = createContext<ThreadScrollButtonContextValue>({ | ||
| isNearBottom: true, | ||
| scrollToBottom: () => {} | ||
| }); | ||
|
|
||
| // TODO: make this more RAC like (aka default class name and other RAC prop) | ||
| interface ThreadProps { | ||
| className?: string; | ||
| style?: CSSProperties; | ||
| children?: ReactNode; | ||
| } | ||
|
|
||
| // TODO: things to look at | ||
| // chatgpt, claude, other AI assistants to see their UX | ||
| // they each don't seem to use column-reverse | ||
|
|
||
| // TODO: things to figure out/try | ||
| // 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 | ||
| // also since we track the last focused key of the Gridlist, you get a experience where you might tab in, go to the input field to add some messages | ||
| // and tab back to the Gridlist but get returned to your last focused key instead of to the newest message | ||
| // maybe we could do something like force that the last item is the internal focusedKey, always updating this to the latest last child | ||
| // whenever items update AND focus is not within the gridlist | ||
|
|
||
| // TODO: things to handle later | ||
| // virtualizer layout | ||
| // weird behavior where the prompt field loses focus everytime you enter something | ||
| // make prompt field accept enter to submit the prompt, and have Option + Enter make a new line instead, mimics | ||
| // other ai chat experiences | ||
|
|
||
| export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thread( | ||
| props: ThreadProps, | ||
| ref: DOMRef<HTMLDivElement> | ||
| ) { | ||
| let {children, className, style} = props; | ||
| let domRef = useDOMRef(ref); | ||
| let isGridListFocusedRef = useRef(false); | ||
| let isFieldFocusedRef = useRef(false); | ||
| let hasNewMessagesRef = useRef(false); | ||
| let timeout = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
|
|
||
| let scrollRef = useRef<HTMLElement | null>(null); | ||
| let scrollToBottom = useCallback(() => { | ||
| scrollRef.current?.scrollTo({top: 0, behavior: 'smooth'}); | ||
| }, []); | ||
| 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 | ||
| 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); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if (isFieldFocusedRef.current) { | ||
| announce(text, 'polite'); | ||
| } | ||
| }, []); | ||
|
|
||
| let setGridListFocused = useCallback((isFocused: boolean) => { | ||
| isGridListFocusedRef.current = isFocused; | ||
| }, []); | ||
|
|
||
| let setScrollElement = useCallback((el: HTMLElement | null) => { | ||
| scrollRef.current = el; | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| return () => { | ||
| if (timeout.current !== null) { | ||
| clearTimeout(timeout.current); | ||
| } | ||
| }; | ||
| }, []); | ||
|
|
||
| return ( | ||
| <Provider | ||
| values={[ | ||
| [ | ||
| InternalThreadContext, | ||
| {announceItem, setGridListFocused, setIsNearBottom, setScrollElement} | ||
| ], | ||
| [ThreadScrollButtonContext, {isNearBottom, scrollToBottom}], | ||
| [ | ||
| TextFieldContext, | ||
| { | ||
| slots: { | ||
| [DEFAULT_SLOT]: {}, | ||
| prompt: { | ||
| onFocusChange: (focused: boolean) => { | ||
| isFieldFocusedRef.current = focused; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| ]}> | ||
| <div ref={domRef} className={className} style={style}> | ||
| {children} | ||
| </div> | ||
| </Provider> | ||
| ); | ||
| }); | ||
|
|
||
| interface ThreadListProps<T extends object> extends Pick< | ||
| GridListProps<T>, | ||
| 'items' | 'children' | 'focusOnEntry' | 'aria-label' | 'aria-labelledby' | ||
| > { | ||
| className?: string; | ||
| } | ||
|
|
||
| export function ThreadList<T extends object>(props: ThreadListProps<T>) { | ||
| let { | ||
| items, | ||
| children, | ||
| className, | ||
| focusOnEntry, | ||
| 'aria-label': ariaLabel, | ||
| 'aria-labelledby': ariaLabelledby | ||
| } = props; | ||
|
|
||
| let {setGridListFocused, setIsNearBottom, setScrollElement} = useContext(InternalThreadContext); | ||
| let isNearBottomRef = useRef(true); | ||
| let gridListRef = useRef<HTMLDivElement | null>(null); | ||
|
|
||
| let callbackRef = useCallback( | ||
| (el: HTMLDivElement | null) => { | ||
| gridListRef.current = el; | ||
| setScrollElement(el); | ||
| }, | ||
| [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) { | ||
| return; | ||
| } | ||
|
|
||
| // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up | ||
| let nearBottom = 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 | ||
| // TODO: seems like other chat agents will scroll you down regardless of where you are in the chat | ||
| // however, as it is streaming the response in, it will allow you to scroll where ever and not pull you back down | ||
| if (isNearBottomRef.current) { | ||
| requestAnimationFrame(() => { | ||
| if (gridListRef.current) { | ||
| gridListRef.current.scrollTop = 0; | ||
| } | ||
| }); | ||
| } | ||
| }, [items]); | ||
|
|
||
| return ( | ||
| <GridList | ||
| ref={callbackRef} | ||
| disallowTypeAhead | ||
| onScroll={handleScroll} | ||
| keyboardNavigationBehavior="tab" | ||
| focusOnEntry={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'}} | ||
| className={className}> | ||
| {children} | ||
| </GridList> | ||
| ); | ||
| } | ||
|
|
||
| interface ThreadScrollButtonProps { | ||
| children?: ReactNode; | ||
| } | ||
|
|
||
| // TODO: wrapper so we can do the "if isNearBottom then hide" logic, could do this via inline styles perhaps | ||
| // and ditch the wrapper? | ||
| export function ThreadScrollButton({children}: ThreadScrollButtonProps) { | ||
| let {isNearBottom, scrollToBottom} = useContext(ThreadScrollButtonContext); | ||
|
|
||
| if (isNearBottom) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <ButtonContext.Provider | ||
| value={{slots: {[DEFAULT_SLOT]: {}, scroll: {onPress: scrollToBottom}}}}> | ||
| {children} | ||
| </ButtonContext.Provider> | ||
| ); | ||
| } | ||
|
|
||
| interface ThreadItemProps extends Pick<GridListItemProps, 'className' | 'children' | 'textValue'> { | ||
| /** Whether or not the item's content is currently being streamed in. */ | ||
| isStreaming?: boolean; | ||
| /** Announce textValue on mount even when isStreaming is provided. */ | ||
| shouldAnnounceOnMount?: boolean; | ||
| } | ||
|
|
||
| export function ThreadItem(props: ThreadItemProps) { | ||
| let {className, children, textValue = ' ', isStreaming, shouldAnnounceOnMount} = props; | ||
| let {announceItem} = useContext(InternalThreadContext); | ||
|
|
||
| // TODO: using aria-live on the gridlist item was pretty chatty and the streaming causes the text announcement | ||
| // to constantly reset. If we used a live region and updated its contents when streaming finished that worked decently | ||
| // but still feels quite verbose. Stick with this and get feedback | ||
| useLayoutEffect(() => { | ||
| if ((isStreaming === undefined || shouldAnnounceOnMount) && textValue && textValue !== ' ') { | ||
| announceItem(textValue); | ||
| } | ||
|
|
||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); | ||
|
|
||
| let isStreamingNow = isStreaming ?? false; | ||
| let prevStreamingRef = useRef(isStreamingNow); | ||
| useLayoutEffect(() => { | ||
| if (isStreaming === undefined) { | ||
| return; | ||
| } | ||
| let wasStreaming = prevStreamingRef.current; | ||
| prevStreamingRef.current = isStreamingNow; | ||
| if (wasStreaming && !isStreamingNow && textValue && textValue !== ' ') { | ||
| announceItem(textValue); | ||
| } | ||
| }, [isStreaming, isStreamingNow, textValue, announceItem]); | ||
|
|
||
| return ( | ||
| <GridListItem textValue={textValue} className={className}> | ||
| {children} | ||
| </GridListItem> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
try aria-live, see if that is better at queuing stuff. Also think about if some announcement should be cut off or not
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add private prop if one doesn't exist to turn off typeahead
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
only have announce when in the thread, and consider not having it announce/do a limited announcement if you are navigating in the thread already.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
make this live in separate ai components repo, will port to RAC later. Keep defaults for things like focusOnEntry but keep the API flexibile/configurable for now