diff --git a/packages/@react-spectrum/s2/src/Thread.tsx b/packages/@react-spectrum/s2/src/Thread.tsx new file mode 100644 index 00000000000..8d4870b278c --- /dev/null +++ b/packages/@react-spectrum/s2/src/Thread.tsx @@ -0,0 +1,157 @@ +/* + * Copyright 2024 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 './ActionButton'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; +import ChevronDown from '../s2wf-icons/S2_Icon_ChevronDown_20_N.svg'; +import {DOMRef, forwardRefType} from '@react-types/shared'; +import {forwardRef, useCallback, useEffect, useRef, useState} from 'react'; +import {GridList, GridListProps} from 'react-aria-components/GridList'; +import {style} from '../style' with {type: 'macro'}; +import {useDOMRef} from './useDOMRef'; + +interface ThreadProps extends Pick, 'items' | 'children'> { + /** Returns the announcement text for an item when it is added to the thread. */ + getItemText?: (item: T) => string; +} + +// 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< + T extends object +>(props: ThreadProps, ref: DOMRef) { + let {children, items, getItemText} = props; + let domRef = useDOMRef(ref); + let isNearBottomRef = useRef(true); + let [showScrollButton, setShowScrollButton] = useState(false); + let seenKeysRef = useRef | null>(null); + + useEffect(() => { + if (!items) { + return; + } + if (seenKeysRef.current === null) { + // make sure we don't announce items that are already in the thread, user can navigate though the thread + // ideally we would have access to the internal state or something so that we could access the keys/id tied to the + // collection items + seenKeysRef.current = new Set([...items]); + return; + } + + if (!getItemText) { + return; + } + + for (let item of items) { + if (!seenKeysRef.current.has(item)) { + seenKeysRef.current.add(item); + announce(getItemText(item), 'polite'); + } + } + }, [items, getItemText]); + + let handleScroll = useCallback(() => { + if (!domRef.current) { + return; + } + + // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up + let nearBottom = domRef.current.scrollTop > -100; + isNearBottomRef.current = nearBottom; + setShowScrollButton(!nearBottom); + }, [domRef]); + + 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 (domRef.current) { + domRef.current.scrollTop = 0; + } + }); + } + }, [items, domRef]); + + let scrollToBottom = useCallback(() => { + if (domRef.current) { + domRef.current.scrollTo({top: 0, behavior: 'smooth'}); + } + }, [domRef]); + + return ( +
+ {/* + TODO this is before the grid list so that a user tabbing in will hit this first + so they can then scroll to bottom. Wonder if there should also be one after the grid list + so that shift tabbing from the input keyboard works + */} + {showScrollButton && ( +
+ + + +
+ )} + + {children} + +
+ ); +}); diff --git a/packages/@react-spectrum/s2/stories/Thread.stories.tsx b/packages/@react-spectrum/s2/stories/Thread.stories.tsx new file mode 100644 index 00000000000..9464f3ec202 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/Thread.stories.tsx @@ -0,0 +1,743 @@ +/* + * 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 '../src/ActionButton'; +import {ActionMenu} from '../src/ActionMenu'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; +import {AssetCard} from '../src/Card'; +import {baseColor, css, focusRing, style} from '../style' with {type: 'macro'}; +import {Button} from '../src/Button'; +import { + ButtonContext, + GridList, + GridListItem, + Group, + isFileDropItem, + Label, + Tag, + TagGroup, + TagList, + TextArea, + TextField, + useDrop +} from 'react-aria-components'; +import {Card, CardPreview} from '../src/Card'; +import CheckmarkCircle from '@react-spectrum/s2/icons/CheckmarkCircle'; +import ChevronRight from '@react-spectrum/s2/icons/ArrowCurved'; +import {CloseButton} from '../src/CloseButton'; +import {Content, Text} from '../src/Content'; +import {Disclosure, DisclosureHeader, DisclosurePanel, DisclosureTitle} from '../src/Disclosure'; +import {Image} from '../src/Image'; +import {Link, LinkProps} from '../src/Link'; +import {ListLayout} from 'react-stately/useVirtualizerState'; +import {MenuItem} from '../src/Menu'; +import type {Meta} from '@storybook/react'; +import Plus from '@react-spectrum/s2/icons/Add'; +import {ProgressCircle} from '../src/ProgressCircle'; +import {ReactNode, useCallback, useEffect, useRef, useState} from 'react'; +import Send from '@react-spectrum/s2/icons/ArrowUpSend'; +import {Thread} from '../src/Thread'; +import ThumbDown from '@react-spectrum/s2/icons/ThumbDown'; +import ThumbUp from '@react-spectrum/s2/icons/ThumbUp'; +import {ToggleButton} from '../src/ToggleButton'; +import {ToggleButtonGroup} from '../src/ToggleButtonGroup'; +import {Virtualizer} from 'react-aria-components/Virtualizer'; + +const meta: Meta = { + component: Thread, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + title: 'Thread', + decorators: [ + Story => ( +
+ +
+ ) + ] +}; + +export default meta; +// type Story = StoryObj; + +export function StaticThread() { + // TODO: problem with this is that we are applying column reverse so tabbing into the collection brings you to the "bottom" item + // but in the static case we would need to flip the order of the static children as well. Maybe unrealistic for a static case to be + // used, but maybe people will do a .map? + // the dynamic case is fine cuz we can flip the order of the items inside Thread + return ( +
+ + +

What would you like to do next?

+
+ + + +
+
+ + + Can you help me create a 45-minute presentation, with animations, for an executive update? + + +
+

+ Big idea/core narrative: The warmth of welcome +

+

+ Hospitality begins the moment our customers set foot off their plane. We are more than + accommodation, and we service a diverse base. We hope to be the anchor and bounce + board for all who stay with us.{' '} +

+

Belonging happens at Hilton

+

+ We strive to be familiar but exceed expectations. These assets highlight how belonging + is personified. +

+

We are more than accommodation

+
    +
  • Airport pick up service
  • +
  • Local recommendations
  • +
  • Everyday excursions
  • +
  • Customizable experience
  • +
+
+ + + + Hilton brand email — Q1 campaign 2026 + Market research — hospitality trends 2025 + User research — loyalty programme survey + + +
+ + + Can you help me create a 45-minute presentation, with animations, for an executive update? + + + + + + + + Desert Sunset + + Edit + Share + Delete + + PNG • 2/3/2024 + + + + + Can you help me create a 45-minute presentation, with animations, for an executive update? + + + + + + + + Hilton commercial assets + + Edit + Share + Delete + + 2026 + + + +
+ +
+ ); +} + +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[]; + +export function DynamicThread() { + 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.type === 'status' + ? msg.status === 'pending' + ? 'Generating response…' + : '' + : msg.content + }> + {msg => { + if (msg.type === 'user') { + return {msg.content}; + } + if (msg.type === 'status') { + return ; + } + return ( + +
+

{msg.content}

+
+ +
+ ); + }} +
+ +
+ ); +} + +export function VirtualizedThread() { + let [messages, setMessages] = useState(initialResponses); + let nextId = useRef(initialResponses.length); + let lastMessage = messages.at(-1); + let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; + let seenKeysRef = useRef | null>(null); + let getItemText = useCallback( + msg => + msg.type === 'status' + ? msg.status === 'pending' + ? 'Generating response…' + : '' + : msg.content, + [] + ); + + 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); + } + + // TODO: this is a copy of what is in thread, merge when we support virtualizer + useEffect(() => { + if (!messages) { + return; + } + if (seenKeysRef.current === null) { + // make sure we don't announce items that are already in the thread, user can navigate though the thread + // ideally we would have access to the internal state or something so that we could access the keys/id tied to the + // collection items + seenKeysRef.current = new Set([...messages]); + return; + } + + if (!getItemText) { + return; + } + + for (let item of messages) { + if (!seenKeysRef.current.has(item)) { + seenKeysRef.current.add(item); + announce(getItemText(item), 'polite'); + } + } + }, [messages, getItemText]); + + return ( +
+ {/* TODO: move this Virtualizer into the Thread component eventually when we get column reverse support */} + + + {/* TODO style these so that they don't become full width in a virtualizer (or at least dont appear visually to be full width) */} + {msg => { + if (msg.type === 'user') { + return {msg.content}; + } + if (msg.type === 'status') { + return ; + } + return ( + +
+

{msg.content}

+
+ +
+ ); + }} +
+
+ +
+ ); +} + +// TODO: all of the below was copied from rsp-prototypes, just filler for now +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)}> + +