From 98cb3d8e7de1c348858f25b8a01ead73044245bf Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 31 Aug 2026 15:01:22 +1000 Subject: [PATCH 01/27] chore: reference s2 ai components --- .../@react-spectrum/ai/src/loader/react.tsx | 2 +- .../dev/s2-docs/pages/s2/AIComponents.mdx | 317 ++++++++++ .../pages/s2/ai-component-helpers/chat.tsx | 559 ++++++++++++++++++ .../s2/ai-component-helpers/promptfield.tsx | 238 ++++++++ 4 files changed, 1115 insertions(+), 1 deletion(-) create mode 100644 packages/dev/s2-docs/pages/s2/AIComponents.mdx create mode 100644 packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx create mode 100644 packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx diff --git a/packages/@react-spectrum/ai/src/loader/react.tsx b/packages/@react-spectrum/ai/src/loader/react.tsx index 6dcdebfc562..fb8f2895e75 100644 --- a/packages/@react-spectrum/ai/src/loader/react.tsx +++ b/packages/@react-spectrum/ai/src/loader/react.tsx @@ -295,7 +295,7 @@ export function PixelLoader(props: PixelLoaderProps) { } return matrix; }, [cells]); - const isHighDPI = window.devicePixelRatio >= 2; + const isHighDPI = (typeof document !== 'undefined' && window.devicePixelRatio >= 2) ?? false; return (
Spectrum has a . + + +```tsx render type="s2" files={["packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx", "packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx"]} +"use client"; +import {useState, useRef} from 'react'; +import { + AttachFileMenuItem, + CommandMenuItem, + InsertTextMenuItem, + InsertTokenMenuItem, + InsertMenuButton, + Prompt, + PromptField, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldTokenValue, + PromptFieldToolbar, + PromptFieldValue, + PromptFieldVoiceButton, + PromptToken, + PromptTokenField, + MessageSuggestion, + MessageSuggestionList +} from '@react-spectrum/ai'; +import {getIcon, prompts, slashCommands, objects, renderCompletions} from './ai-component-helpers/promptfield'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; +import * as data from '@react-spectrum/ai/loader'; +import {Collection, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; +import Data from '@react-spectrum/s2/icons/Data'; +import Plugin from '@react-spectrum/s2/icons/Plugin'; +import Prompt from '@react-spectrum/s2/icons/Prompt'; +import {VirtualizedStreamingChat} from './ai-component-helpers/chat'; + + +function Example(args) { + let {placeholder, menuWidth, ...otherArgs} = args; + let [value, setValue] = useState(() => new PromptFieldValue([])); + let promptFieldRef = useRef>(null); + let [attachments, setAttachments] = useState([]); + let [attachmentState, setAttachmentState] = useState>(new Map()); + let historyRef = useRef([]); + let historyIndexRef = useRef(-1); + let isHistoryNavigating = useRef(false); + + let mockUpload = async (id: string) => { + await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); + setAttachmentState(prev => { + let item = prev.get(id); + if (!item || item.status === 'completed') { + return prev; + } + let newState = new Map(prev); + let progress = (item.progress ?? 0) + 1; + if (progress >= 100) { + newState.set(id, {status: 'completed'}); + } else { + newState.set(id, {status: 'uploading', progress}); + mockUpload(id); + } + return newState; + }); + }; + + let handleChange = (newValue: TokenFieldValue) => { + if (!isHistoryNavigating.current) { + // if user edits the field, then we want to reset the index so up arrow starts from latest prompt again + historyIndexRef.current = -1; + } + isHistoryNavigating.current = false; + setValue(newValue); + }; + + return ( +
+ +
+ + {prompts.map((prompt, i) => ( + { + setValue(prompt); + promptFieldRef.current?.focus(); + }}> + {prompt.segments.map((s, i) => + s.type === 'token' ? ( + + {getIcon(s) && {getIcon(s)}} + {s.text} + + ) : ( + s.text + ) + )} + + ))} + + { + historyRef.current = [...historyRef.current, prompt]; + historyIndexRef.current = -1; + setValue(new PromptFieldValue([])); + setAttachments([]); + setAttachmentState(new Map()); + }} + acceptedAttachmentTypes={['*/*']} + onAddAttachments={newAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + newAttachments.forEach(attachment => { + newState.set(attachment.id, {status: 'uploading', progress: 0}); + mockUpload(attachment.id); + }); + return newState; + }); + }} + onRemoveAttachments={removedAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + removedAttachments.forEach(attachment => { + newState.delete(attachment.id); + }); + return newState; + }); + }}> + + {attachment => { + let state = attachmentState.get(attachment.id); + return ( + + + {args.attachmentVariant === 'card' && ( + + {attachment.file.name} + + {attachment.file.type.split('/').pop()?.toUpperCase()} + + + )} + + ); + }} + + { + return renderCompletions(filterValue, { + valueType, + onClear: () => { + setValue(new PromptFieldValue([])); + setAttachments([]); + } + }); + }} + pixelLoader={data[args.pixelLoader]} + shouldAnimatePixelLoader + placeholder={placeholder} + menuWidth={menuWidth}> + {token => ( + + {getIcon(token)} + {token.text} + + )} + + +
+ + + + + + Commands + + item.kind === 'command')}> + {item => + item.command === '/clear' ? ( + { + setValue(new PromptFieldValue([])); + setAttachments([]); + }}> + {item.command} + {item.description} + + ) : item.command === '/compact' ? ( + console.log('onCompact')}> + {item.command} + {item.description} + + ) : item.command === '/feedback' || item.command === '/btw' ? ( + + {item.command} + {item.description} + + ) : ( + + {item.command} + {item.description} + + ) + } + + + + + + Skills + + item.kind === 'skill')}> + {item => ( + + {item.command} + {item.description} + + )} + + + + + + Reference an object + + + {item => ( + +
+ {item.section} +
+ + {item => ( + + {item.title} + + )} + +
+ )} +
+
+
+
+ {/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */} +
+ console.log('onToggle')} /> + +
+
+
+
+
+
+ ); +} +``` diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx new file mode 100644 index 00000000000..da5d6183fa9 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -0,0 +1,559 @@ +import {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; +import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; +import ChatIcon from '@react-spectrum/s2/icons/Chat'; +import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; +import {Collection} from 'react-aria-components'; +import {Content} from '@react-spectrum/s2/Content'; +import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; +import {Image} from '@react-spectrum/s2/Image'; +import {MenuItem} from '@react-spectrum/s2/Menu'; +import { + Chat, + MessageFeedback, + MessageSource, + MessageSuggestion, + MessageSuggestionList, + PromptFieldValue, + ResponseStatus, + ResponseStatusPanel, + ResponseStatusTitle, + SourceList, + SourceListItem, + Thread, + ThreadItem, + ThreadLoadMoreItem, + ThreadScrollButton, + TokenFieldValue, + UserMessage +} from '@react-spectrum/ai'; +import type {Meta} from '@storybook/react'; +import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; +import {ReactNode, useCallback, useEffect, useRef, useState} from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Text} from '@react-spectrum/s2/Text'; + +type Message = + | {id: number; type: 'user' | 'system'; content: string} + | {id: number; type: 'status'; status: 'pending' | 'complete'}; + +let initialResponses = [ + { + id: 0, + type: 'user', + content: + "I'm launching a new product next month—a sustainable, affordable athletic wear line targeting Gen Z. We have a limited budget and need to figure out our go-to-market strategy. Where do we even start?" + }, + { + id: 1, + type: 'system', + content: + "Exciting launch! A few things to clarify so I give you relevant guidance: (1) What's your total marketing budget for the launch month? (2) Do you already have an email list, social following, or any existing audience? (3) Who's your main competitor, and what are they doing? (4) Is this DTC (direct-to-consumer) or are you selling through retailers? These will shape whether we focus on paid ads, influencers, organic content, or a mix." + }, + { + id: 2, + type: 'user', + content: + "We have about $15,000 for the first month. We're DTC only, launching on our own website. We have a small email list of about 2,000 people from a waitlist, and basically no social following yet. Our main competitor is Reformation, but they're way more established. We want to emphasize our sustainability angle and lower price point." + }, + { + id: 3, + type: 'system', + content: + "Okay, $15K is modest but workable for DTC. Here's my initial take: Since you're starting from zero on social and have a small email list, I'd suggest a three-part approach: (1) Allocate ~$8K to highly targeted paid ads (TikTok and Instagram) reaching eco-conscious Gen Z with strong creative, (2) Use $3K for micro-influencer partnerships (people with 10K-100K followers in the sustainability space—cheaper but authentic), (3) Keep $4K for email and organic content. Before we dive deeper—what's your product's main hook? Is it the materials, the price, the designs, or the transparency of your supply chain?" + }, + { + id: 4, + type: 'user', + content: + "The main hook is that we use recycled ocean plastic and sell at 40% lower prices than Reformation. Our supply chain is also fully transparent. We've got 15 styles launching across tops, shorts, and leggings." + }, + { + id: 5, + type: 'system', + content: + "Perfect—that's a strong, differentiated message. Here's what I'd focus on: The ocean plastic angle is visually compelling and emotionally resonant with Gen Z; the price-to-quality ratio is your competitive advantage against Reformation. For your first month, I'd recommend this campaign narrative: 'High performance, zero guilt, actual prices.' Now, a strategic question: Do you want to go all-in on one or two channels, or spread across TikTok, Instagram, and YouTube? Gen Z is on TikTok and Instagram, but each requires different content styles. What does your creative team feel most confident producing?" + }, + { + id: 6, + type: 'user', + content: + "We can handle both TikTok and Instagram, but honestly TikTok feels more authentic for our brand. We have some behind-the-scenes footage of our production process and some early customer testimonials. Instagram we'd probably do more polished product shots." + }, + { + id: 7, + type: 'system', + content: + "Smart instinct—TikTok is perfect for you because behind-the-scenes and authenticity is the content style there, and sustainability messaging performs really well. That said, here's my suggestion: (1) Use TikTok primarily for awareness and virality—behind-the-scenes, sustainability stories, affordability messaging. Spend $5K there. (2) Use Instagram for conversion—polished product shots, customer testimonials, sustainability proof. Spend $3K there. (3) With your micro-influencers ($3K), focus on TikTok creators first since that's where your audience is. One question though: Do you have a clear conversion path ready? Like, when someone clicks your ad, what's the landing page experience? Are you doing a discount code to incentivize first purchase?" + }, + { + id: 8, + type: 'user', + content: + "We have the website ready, but we haven't thought about a launch discount. Should we do one? Won't that hurt our perceived value?" + }, + { + id: 9, + type: 'system', + content: + "Great question—common concern. For a DTC launch with limited awareness, I'd actually recommend a strategic discount, not a deep one. Something like 15-20% off for the first 48 hours, with messaging like 'Early founder pricing' or 'Launch week exclusive.' Why? It creates urgency, lowers the barrier for first-time buyers trying an unknown brand, and you get email addresses and reviews to leverage later. You're not training people to wait for sales—you're using urgency to convert. Pair it with clear value messaging (ocean plastic, transparent supply chain, high quality) so it doesn't feel cheap. Also, include a referral incentive—Gen Z shares—maybe $10 off for them and the referee. This turns customers into advocates. Does that strategy feel right for your brand?" + }, + { + id: 10, + type: 'user', + content: + 'That makes sense. Before we move forward, can you summarize the full plan and tell me what could go wrong?' + }, + { + id: 11, + type: 'system', + content: + "Absolutely. Your Launch Plan: $8K paid ads (TikTok $5K, Instagram $3K) with behind-the-scenes and product content; $3K micro-influencer partnerships on TikTok; $4K allocated to email nurture and organic/referral growth. 15-20% launch discount for 48 hours. What could go wrong: (1) Creative underperforms—test multiple ad versions early and kill losers fast, (2) Website crashes or checkout breaks—stress test before launch, (3) Influencers don't deliver—vet their audiences carefully and look for engagement rates, not just follower count, (4) You don't have enough inventory—make sure supply matches demand projections, (5) Customer acquisition cost exceeds your margins—monitor CAC closely in week one and adjust spending. My advice: Launch on a Tuesday or Wednesday so you have time to optimize before the weekend. Track everything obsessively in the first 72 hours—CAC, conversion rate, email signup rate. Be ready to shift budget if one channel is outperforming. Want me to help you build a monitoring dashboard or refine your email launch sequence next?" + } +] as Message[]; + +type StreamingMessage = + | {id: number; type: 'user'; content: string} + | {id: number; type: 'system'; content: string; isStreaming?: boolean; sources?: string[]} + | { + id: number; + type: 'status'; + label: string; + isStreaming: boolean; + details: string; + } + | {id: number; type: 'card'; title: string; description: string; imageUrl: string} + | {id: number; type: 'suggestions'; title: string; suggestions: string[]}; + +let MOCK_SOURCES = [ + 'Hilton brand email — Q1 campaign 2026', + 'Market research — hospitality trends 2025', + 'User research — loyalty programme survey' +]; + +let MOCK_SUGGESTIONS = [ + 'Suggest a presentation structure', + 'What other assets might be relevant?', + 'Summarize the key themes' +]; + +let MOCK_CARD = { + title: 'Desert Sunset', + description: 'PNG • 2/3/2024', + imageUrl: + 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' +}; + +function CardMessage({ + title, + description, + imageUrl +}: { + title: string; + description: string; + imageUrl: string; +}) { + return ( + + + + + + + {title} + + Edit + Share + Delete + + {description} + + + + ); +} + +export function VirtualizedStreamingChat(props) { + let [messages, setMessages] = useState( + initialResponses as StreamingMessage[] + ); + let nextId = useRef(initialResponses.length); + let [isGenerating, setGenerating] = useState(false); + let timeouts = useRef([]); + let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); + let followUpMessage = useRef(null); + + function handleSend(prompt: TokenFieldValue) { + setGenerating(true); + // user message added first so its announcement plays before + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: prompt.toString()} + ]); + + function addTool(label: string, replaceStatus = false) { + setMessages(prev => + replaceStatus + ? [ + ...prev.slice(0, -1), + { + id: nextId.current++, + type: 'status', + label, + isStreaming: true, + details: '' + } + ] + : [ + ...prev, + { + id: nextId.current++, + type: 'status', + label, + isStreaming: true, + details: '' + } + ] + ); + } + + function completeTool(details: string) { + setMessages(prev => + prev.map(m => + m.type === 'status' && m.isStreaming ? {...m, isStreaming: false, details} : m + ) + ); + } + + function streamText(content: string, sources?: string[]) { + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} + ]); + let tokens = content.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + setTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + let isLastToken = i === tokens.length - 1; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? { + ...m, + content: accumulated, + isStreaming: !isLastToken, + ...(isLastToken && sources ? {sources} : {}) + } + : m + ) + ); + }, i * 80); + }); + } + + let addTimeout = (callback: () => void, delay: number) => { + let timeout = setTimeout(callback, delay); + timeouts.current.push(timeout); + return timeout; + }; + + // TODO: these durations are quite generous in order to accomodate for announcements, but realistically it might be + // faster and thus the announcements will get cut off even with polite... + // first batch, does tool calls with text response + let timestamp = 0; + let toolCallDuration = 1000; + // Status added after short delay so user message announcement plays first + addTimeout( + () => { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + label: 'Generating response', + isStreaming: true, + details: '' + } + ]); + }, + (timestamp += 500) + ); + addTimeout(() => addTool('Thinking', true), (timestamp += 500)); + addTimeout( + () => + completeTool( + 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' + ), + (timestamp += toolCallDuration) + ); + addTimeout(() => addTool('Loading tool'), (timestamp += 500)); + addTimeout( + () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => addTool('Searching'), (timestamp += 500)); + addTimeout( + () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), + (timestamp += toolCallDuration) + ); + addTimeout( + () => + streamText( + 'I found some relevant assets that match your request. Let me pull up the details.' + ), + (timestamp += 500) + ); + + // then does searching, streaming more text, returning a card and sources + addTimeout(() => addTool('Searching'), (timestamp += 1000)); + addTimeout( + () => + completeTool('Identified additional brand materials related to the presentation context.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => addTool('Querying database'), (timestamp += 1000)); + addTimeout( + () => + completeTool( + 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' + ), + (timestamp += toolCallDuration) + ); + addTimeout( + () => + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + label: 'Generating response', + isStreaming: true, + details: '' + } + ]), + (timestamp += 500) + ); + addTimeout( + () => + setMessages(prev => [ + ...prev.slice(0, -1), + { + id: nextId.current++, + type: 'status', + label: 'Response generated', + isStreaming: false, + details: + 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' + } + ]), + (timestamp += 1000) + ); + let secondStreamContent = + 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + + 'layering in supporting visuals and data to reinforce the core message. The main themes ' + + 'revolve around brand consistency, audience engagement, and clear calls to action.'; + addTimeout(() => streamText(secondStreamContent, MOCK_SOURCES), (timestamp += 500)); + + let streamEndTimestamp = timestamp + (secondStreamContent.split(' ').length - 1) * 80 + 500; + addTimeout(() => { + setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); + }, streamEndTimestamp); + addTimeout(() => { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'suggestions', + title: 'Suggested follow-ups', + suggestions: MOCK_SUGGESTIONS + } + ]); + setGenerating(false); + }, streamEndTimestamp + 1000); + } + + useEffect(() => { + if (!isGenerating && followUpMessage.current) { + let followup = followUpMessage.current; + followUpMessage.current = null; + handleSend(followup); + } + }, [isGenerating]); + + // TODO: maybe also have it finalize any in progress tool calls and what not, but do it later + function handleStop() { + followUpMessage.current = null; + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + setMessages(prev => + prev.map(m => + (m.type === 'system' || m.type === 'status') && m.isStreaming + ? {...m, isStreaming: false} + : m + ) + ); + setGenerating(false); + } + + return ( + // TODO: these extra div wrappers would need to be implemented by the RAC user, maybe we can internalize some more? + // of particular note is the scroll button. Same for the other styles +
+ +
+
+ + + + + +
+ + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + // TODO: probably want ThreadItem to be a part of UserMessage? + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; + let title = msg.isStreaming ? `${msg.label}…` : msg.label; + // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? + // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we + // probably want to make that on a case by case basis + // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) + return ( + + + {title} + + {msg.details && ( +

{msg.details}

+ )} +
+
+
+ ); + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + // TODO: probably should have ThreadItem auto wrap MessageSuggestionList as well + // but this one I could see perhaps being a standalone component to be used outside of thread + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } + return ( + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
+ ); + }} +
+
+ {props.children} +
+
+ ); +} + +function SystemMessage({ + children, + textValue = ' ', + isStreaming, + sources +}: { + children: ReactNode; + textValue?: string; + isStreaming?: boolean; + sources?: string[]; +}) { + return ( + + {children} + {sources && sources.length > 0 && ( + + + {sources.map((s, i) => ( + + {s} + + ))} + + + )} + + ); +} diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx new file mode 100644 index 00000000000..52f51c1f9d7 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx @@ -0,0 +1,238 @@ +import { + CommandMenuItem, + InsertTextMenuItem, + InsertTokenMenuItem, + PromptFieldTokenValue, + PromptFieldValue +} from '@react-spectrum/ai'; +import Brand from '@react-spectrum/s2/icons/Brand'; +import {Header, Heading, MenuItem, MenuSection, Text} from '@react-spectrum/s2/Menu'; +import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; +import LinkIcon from '@react-spectrum/s2/icons/Link'; +import Plugin from '@react-spectrum/s2/icons/Plugin'; +import Prompt from '@react-spectrum/s2/icons/Prompt'; +import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; +import {TokenSegment} from 'react-stately'; +import UserGroup from '@react-spectrum/s2/icons/UserGroup'; + +export const slashCommands = [ + { + command: '/audience-explainer', + kind: 'skill', + description: 'Explain an AEP audience in english' + }, + {command: '/btw', kind: 'command', description: 'Ask a side question'}, + {command: '/clear', kind: 'command', description: 'Clear the context'}, + {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, + {command: '/dataset-usage', kind: 'skill', description: 'Explain how to use a dataset'}, + {command: '/feedback', kind: 'command', description: 'Submit feedback'}, + {command: '/plan', kind: 'command', description: 'Create a plan before executing'}, + {command: '/visual-artifact', kind: 'skill', description: 'Generate a chart or graph'} +]; + +const icons = { + command: , + skill: , + audience: , + campaign: , + journey: , + url: +} as const; + +export function getIcon(token: TokenSegment) { + switch (token.value?.type) { + case 'placeholder': + return token.value.placeholderType === 'token' && token.value.valueType + ? icons[token.value.valueType] + : null; + case 'url': + return icons.url; + case 'custom': + return icons[token.value.valueType]; + } +} + +export const objects = [ + { + section: 'Audiences', + type: 'audience', + items: [ + {kind: 'audience', title: 'New Customers'}, + {kind: 'audience', title: 'Returning Customers'}, + {kind: 'audience', title: 'Loyal Customers'}, + {kind: 'audience', title: 'High-Value Customers'}, + {kind: 'audience', title: 'Low-Value Customers'} + ] + }, + { + section: 'Campaigns', + type: 'campaign', + items: [ + {kind: 'campaign', title: 'Spring Launch 2026'}, + {kind: 'campaign', title: 'Holiday Cheer'}, + {kind: 'campaign', title: 'Back to School'}, + {kind: 'campaign', title: 'Summer Adventure'}, + {kind: 'campaign', title: 'Tech Trends Expo'} + ] + }, + { + section: 'Journeys', + type: 'journey', + items: [ + {kind: 'journey', title: 'Welcome Flow'}, + {kind: 'journey', title: 'Abandoned Cart Recovery'}, + {kind: 'journey', title: 'Post-Purchase Follow-up'}, + {kind: 'journey', title: 'Re-engagement Campaign'}, + {kind: 'journey', title: 'Birthday Surprise Journey'} + ] + } +]; + +interface CompletionCallbacks { + valueType?: string | null; + onClear?: () => void; + onCompact?: () => void; +} + +export function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) { + if (filterValue.startsWith('/')) { + return slashCommands + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) + .map(item => + item.command === '/clear' ? ( + + + {item.command} + {item.description} + + ) : item.command === '/compact' ? ( + + + {item.command} + {item.description} + + ) : item.command === '/feedback' || item.command === '/btw' ? ( + // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing + + + {item.command} + {item.description} + + ) : ( + + {item.kind === 'skill' ? : } + {item.command} + {item.description} + + ) + ); + } else if (filterValue.startsWith('@')) { + return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) + .map(section => { + let matchingItems = section.items + .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(item => ( + + {item.title} + + )); + + if (matchingItems.length > 0) { + return ( + +
+ {section.section} +
+ {matchingItems} +
+ ); + } else { + return null; + } + }) + .filter(v => v != null); + } + return null; +} + +interface UploadState { + status: 'uploading' | 'completed'; + progress?: number; +} + +function atEnd(v: PromptFieldValue) { + let segs = v.segments; + return {index: segs.length - 1, offset: segs[segs.length - 1].text.length}; +} + +let prompt1 = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + { + type: 'token', + text: 'New Customers', + value: {type: 'custom', anchor: '@', valueType: 'audience', data: {title: 'New Customers'}} + }, + {type: 'text', text: ' and suggest targeting strategies'} +]); + +let prompt2 = new PromptFieldValue([ + {type: 'text', text: 'Write a brief for '}, + { + type: 'token', + text: 'Spring Launch 2026', + value: {type: 'custom', anchor: '@', valueType: 'campaign', data: {title: 'Spring Launch 2026'}} + } +]); + +let prompt3Base = new PromptFieldValue([ + {type: 'text', text: 'Summarize the '}, + { + type: 'token', + text: 'Welcome Flow', + value: {type: 'custom', anchor: '@', valueType: 'journey', data: {title: 'Welcome Flow'}} + } +]); + +let prompt4 = new PromptFieldValue( + [ + {type: 'text', text: 'Detect audiences in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' that changed significantly in the past '}, + {type: 'token', text: 'date', value: {type: 'placeholder', placeholderType: 'text'}} + ] + // {selectedRange: new TokenFieldValue.SelectedRange({index: 1, offset: 0}, {index: 1, offset: 1})} +); + +export const prompts = [ + prompt1.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt1))), + prompt2.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt2))), + prompt3Base.replaceRange( + atEnd(prompt3Base), + atEnd(prompt3Base), + ' journey performance from test.com ' + ), + prompt4 +]; From 86a1d511fd09138806ecad8d0a98e4d4a0d0eba2 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 10:56:50 -0500 Subject: [PATCH 02/27] lint: fix docs type check --- .../ai/src/speech-recognition.d.ts | 96 ++++++++++--------- .../dev/s2-docs/pages/s2/AIComponents.mdx | 8 +- .../pages/s2/ai-component-helpers/chat.tsx | 25 +---- .../s2/ai-component-helpers/promptfield.tsx | 2 +- 4 files changed, 57 insertions(+), 74 deletions(-) diff --git a/packages/@react-spectrum/ai/src/speech-recognition.d.ts b/packages/@react-spectrum/ai/src/speech-recognition.d.ts index 1be6fe65bfd..dee76da9a91 100644 --- a/packages/@react-spectrum/ai/src/speech-recognition.d.ts +++ b/packages/@react-spectrum/ai/src/speech-recognition.d.ts @@ -18,57 +18,61 @@ // lib.dom and are not redeclared. // Spec: https://wicg.github.io/speech-api/ -interface SpeechRecognitionEvent extends Event { - readonly resultIndex: number; - readonly results: SpeechRecognitionResultList; -} +export {}; -interface SpeechRecognitionErrorEvent extends Event { - readonly error: SpeechRecognitionErrorCode; - readonly message: string; -} +declare global { + interface SpeechRecognitionEvent extends Event { + readonly resultIndex: number; + readonly results: SpeechRecognitionResultList; + } -interface SpeechRecognition extends EventTarget { - lang: string; - continuous: boolean; - interimResults: boolean; - maxAlternatives: number; - start(): void; - stop(): void; - abort(): void; - onaudiostart: ((this: SpeechRecognition, ev: Event) => void) | null; - onsoundstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onspeechstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onspeechend: ((this: SpeechRecognition, ev: Event) => void) | null; - onsoundend: ((this: SpeechRecognition, ev: Event) => void) | null; - onaudioend: ((this: SpeechRecognition, ev: Event) => void) | null; - onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; - onnomatch: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; - onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; - onstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onend: ((this: SpeechRecognition, ev: Event) => void) | null; -} + interface SpeechRecognitionErrorEvent extends Event { + readonly error: SpeechRecognitionErrorCode; + readonly message: string; + } -interface SpeechRecognitionConstructor { - new (): SpeechRecognition; -} + interface SpeechRecognition extends EventTarget { + lang: string; + continuous: boolean; + interimResults: boolean; + maxAlternatives: number; + start(): void; + stop(): void; + abort(): void; + onaudiostart: ((this: SpeechRecognition, ev: Event) => void) | null; + onsoundstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onspeechstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onspeechend: ((this: SpeechRecognition, ev: Event) => void) | null; + onsoundend: ((this: SpeechRecognition, ev: Event) => void) | null; + onaudioend: ((this: SpeechRecognition, ev: Event) => void) | null; + onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; + onnomatch: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; + onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; + } -interface Window { - SpeechRecognition?: SpeechRecognitionConstructor; - webkitSpeechRecognition?: SpeechRecognitionConstructor; -} + interface SpeechRecognitionConstructor { + new (): SpeechRecognition; + } -// User-Agent Client Hints — present in Chromium, absent in Safari/Firefox. -interface NavigatorUABrandVersion { - readonly brand: string; - readonly version: string; -} + interface Window { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; + } -interface NavigatorUAData { - readonly brands: ReadonlyArray; - readonly platform?: string; -} + // User-Agent Client Hints — present in Chromium, absent in Safari/Firefox. + interface NavigatorUABrandVersion { + readonly brand: string; + readonly version: string; + } + + interface NavigatorUAData { + readonly brands: ReadonlyArray; + readonly platform?: string; + } -interface Navigator { - readonly userAgentData?: NavigatorUAData; + interface Navigator { + readonly userAgentData?: NavigatorUAData; + } } diff --git a/packages/dev/s2-docs/pages/s2/AIComponents.mdx b/packages/dev/s2-docs/pages/s2/AIComponents.mdx index 6821278eba6..1af18db0b9a 100644 --- a/packages/dev/s2-docs/pages/s2/AIComponents.mdx +++ b/packages/dev/s2-docs/pages/s2/AIComponents.mdx @@ -20,21 +20,23 @@ import { InsertTextMenuItem, InsertTokenMenuItem, InsertMenuButton, - Prompt, PromptField, + Attachment, + AttachmentPreview, PromptFieldAttachment, PromptFieldAttachmentList, PromptFieldSubmitButton, - PromptFieldTokenValue, PromptFieldToolbar, PromptFieldValue, PromptFieldVoiceButton, PromptToken, PromptTokenField, + TokenFieldValue, MessageSuggestion, MessageSuggestionList } from '@react-spectrum/ai'; -import {getIcon, prompts, slashCommands, objects, renderCompletions} from './ai-component-helpers/promptfield'; +import {type FocusableRefValue} from '@react-types/shared'; +import {getIcon, prompts, slashCommands, objects, renderCompletions, type UploadState} from './ai-component-helpers/promptfield'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import * as data from '@react-spectrum/ai/loader'; diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index da5d6183fa9..a6c77f79e4e 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -1,11 +1,8 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; -import ChatIcon from '@react-spectrum/s2/icons/Chat'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; -import {Collection} from 'react-aria-components'; import {Content} from '@react-spectrum/s2/Content'; -import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; import {Image} from '@react-spectrum/s2/Image'; import {MenuItem} from '@react-spectrum/s2/Menu'; import { @@ -14,7 +11,6 @@ import { MessageSource, MessageSuggestion, MessageSuggestionList, - PromptFieldValue, ResponseStatus, ResponseStatusPanel, ResponseStatusTitle, @@ -22,15 +18,12 @@ import { SourceListItem, Thread, ThreadItem, - ThreadLoadMoreItem, ThreadScrollButton, TokenFieldValue, UserMessage } from '@react-spectrum/ai'; -import type {Meta} from '@storybook/react'; -import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; -import {ReactNode, useCallback, useEffect, useRef, useState} from 'react'; +import {ReactNode, useEffect, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Text} from '@react-spectrum/s2/Text'; @@ -181,7 +174,6 @@ export function VirtualizedStreamingChat(props) { let nextId = useRef(initialResponses.length); let [isGenerating, setGenerating] = useState(false); let timeouts = useRef([]); - let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); let followUpMessage = useRef(null); function handleSend(prompt: TokenFieldValue) { @@ -382,21 +374,6 @@ export function VirtualizedStreamingChat(props) { } }, [isGenerating]); - // TODO: maybe also have it finalize any in progress tool calls and what not, but do it later - function handleStop() { - followUpMessage.current = null; - timeouts.current.forEach(clearTimeout); - timeouts.current = []; - setMessages(prev => - prev.map(m => - (m.type === 'system' || m.type === 'status') && m.isStreaming - ? {...m, isStreaming: false} - : m - ) - ); - setGenerating(false); - } - return ( // TODO: these extra div wrappers would need to be implemented by the RAC user, maybe we can internalize some more? // of particular note is the scroll button. Same for the other styles diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx index 52f51c1f9d7..2b059df02a6 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx @@ -174,7 +174,7 @@ export function renderCompletions(filterValue: string, callbacks?: CompletionCal return null; } -interface UploadState { +export interface UploadState { status: 'uploading' | 'completed'; progress?: number; } From 32331ce3bb73d031f3d44ff1ee4659358654205f Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 10:57:37 -0500 Subject: [PATCH 03/27] rename to ai-components.mdx (matches guide naming) --- .../dev/s2-docs/pages/s2/{AIComponents.mdx => ai-components.mdx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/dev/s2-docs/pages/s2/{AIComponents.mdx => ai-components.mdx} (100%) diff --git a/packages/dev/s2-docs/pages/s2/AIComponents.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx similarity index 100% rename from packages/dev/s2-docs/pages/s2/AIComponents.mdx rename to packages/dev/s2-docs/pages/s2/ai-components.mdx From eaf04de65e147afa13732b88453c3cbc7ca9940c Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 11:05:01 -0500 Subject: [PATCH 04/27] add page description --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 1af18db0b9a..b21ce94dd0c 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -4,11 +4,11 @@ export default Layout; export const section = 'Guides'; export const tags = ['spectrum', 'ai']; -export const description = 'How to use AI components.'; +export const description = 'Build AI-powered experiences with prompts, messages, suggestions, attachments, and voice input.'; # AI Components -Spectrum has a . +React Spectrum provides components for building AI-powered experiences, including prompts, messages, suggestions, attachments, and voice input. ```tsx render type="s2" files={["packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx", "packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx"]} From 62f15003815e296a0595be7e0a5c1ce5d3e9bff5 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 11:32:03 -0500 Subject: [PATCH 05/27] use progressive sections --- .../dev/s2-docs/pages/s2/ai-components.mdx | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index b21ce94dd0c..92737ef6ba9 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -10,6 +10,228 @@ export const description = 'Build AI-powered experiences with prompts, messages, React Spectrum provides components for building AI-powered experiences, including prompts, messages, suggestions, attachments, and voice input. +## Prompt field + +Use `PromptField` as the foundation for allowing the user to submit a prompt. It manages an editable sequence of text and tokens, while `PromptTokenField` renders the input and `PromptFieldToolbar` contains its actions. + +```tsx render type="s2" +"use client"; +import {useState} from 'react'; +import { + PromptField, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptTokenField, + TokenFieldValue +} from '@react-spectrum/ai'; + +function BasicPrompt() { + let [value, setValue] = useState(() => new PromptFieldValue([])); + + return ( + { + console.log(value.toString()); + setValue(new PromptFieldValue([])); + }}> + + + + + + ); +} +``` + +## Suggestions and tokens + +Suggestions can prefill a prompt, and a token field can offer context-aware completions such as mentions or commands. + +```tsx render type="s2" +"use client"; +import {useState} from 'react'; +import { + InsertTokenMenuItem, + MessageSuggestion, + MessageSuggestionList, + PromptField, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptTokenField, + TokenFieldValue +} from '@react-spectrum/ai'; + +let suggestions = ['Summarize this report', 'Draft a project brief', 'Find risks in this plan']; +let people = ['Customers', 'Designers', 'Developers']; + +function PromptSuggestions() { + let [value, setValue] = useState(() => new PromptFieldValue([])); + + return ( +
+ + {suggestions.map(suggestion => ( + setValue(new PromptFieldValue([{type: 'text', text: suggestion}]))}> + {suggestion} + + ))} + + + + people + .filter(person => person.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(person => ( + + {person} + + )) + } + placeholder="Ask about @customers" /> + + + + +
+ ); +} +``` + +## Attachments and actions + +Add attachments and place common actions in the toolbar. Use a custom attachment render function when an attachment needs a thumbnail or additional metadata. + +```tsx render type="s2" +"use client"; +import {useState} from 'react'; +import { + Attachment, + AttachmentPreview, + AttachFileMenuItem, + InsertMenuButton, + InsertTextMenuItem, + PromptField, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldVoiceButton, + PromptFieldValue, + PromptTokenField +} from '@react-spectrum/ai'; + +function PromptAttachments() { + let [attachments, setAttachments] = useState([]); + + return ( + + + {attachment => ( + + + + )} + + + + + + + Summarize image + + + + + + + ); +} +``` + +## Chat + +Compose a conversation from `Chat`, `Thread`, and message components. The thread can be driven by a collection as messages arrive from your application. + +```tsx render type="s2" +"use client"; +import {Chat, Thread, ThreadItem, UserMessage} from '@react-spectrum/ai'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +let messages = [ + {id: 1, type: 'user', text: 'Summarize the campaign results.'}, + {id: 2, type: 'assistant', text: 'Engagement increased 18% this month, led by email and social.'} +]; + +function BasicChat() { + return ( + +
+ + {message => + message.type === 'user' ? ( + + {message.text} + + ) : ( + {message.text} + ) + } + +
+
+ ); +} +``` + +## Complete example + +This example combines prompt suggestions, token completions, attachments, toolbar actions, and a streaming chat thread. ```tsx render type="s2" files={["packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx", "packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx"]} "use client"; @@ -48,6 +270,7 @@ import {VirtualizedStreamingChat} from './ai-component-helpers/chat'; function Example(args) { + /*- begin highlight -*/ let {placeholder, menuWidth, ...otherArgs} = args; let [value, setValue] = useState(() => new PromptFieldValue([])); let promptFieldRef = useRef>(null); @@ -56,6 +279,7 @@ function Example(args) { let historyRef = useRef([]); let historyIndexRef = useRef(-1); let isHistoryNavigating = useRef(false); + /*- end highlight -*/ let mockUpload = async (id: string) => { await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); @@ -89,6 +313,7 @@ function Example(args) {
+ {/*- begin highlight -*/} {prompts.map((prompt, i) => ( ))} + {/*- end highlight -*/} + {/*- begin highlight -*/} {attachment => { @@ -186,6 +413,8 @@ function Example(args) { ); }} + {/*- end highlight -*/} + {/*- begin highlight -*/} { @@ -208,6 +437,8 @@ function Example(args) { )} + {/*- end highlight -*/} + {/*- begin highlight -*/}
@@ -310,6 +541,7 @@ function Example(args) {
+ {/*- end highlight -*/}
From c679acbbadf051fb24a716f037b461778a901a42 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 12:12:40 -0500 Subject: [PATCH 06/27] group consecutive tool/status messages (Thinking, Loading tool, Searching, etc.) into a single status message --- .../ai/stories/Chat.stories.tsx | 270 +++++++++--------- .../pages/s2/ai-component-helpers/chat.tsx | 230 ++++++++------- 2 files changed, 268 insertions(+), 232 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index c1c835e28af..b7b695e3d0c 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -20,9 +20,9 @@ import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {Collection} from 'react-aria-components'; import {Content} from '@react-spectrum/s2/Content'; import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; -import {Image} from '@react-spectrum/s2/Image'; -import {MenuItem} from '@react-spectrum/s2/Menu'; import { + ExecutionTrace, + ExecutionTraceItem, MessageFeedback, MessageSource, MessageSuggestion, @@ -43,6 +43,8 @@ import { TokenFieldValue, UserMessage } from '@react-spectrum/ai'; +import {Image} from '@react-spectrum/s2/Image'; +import {MenuItem} from '@react-spectrum/s2/Menu'; import type {Meta} from '@storybook/react'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {prose} from '../src/style/prose' with {type: 'macro'}; @@ -147,15 +149,21 @@ let initialResponses = [ } ] as Message[]; +interface ExecutionStep { + id: number; + label: string; + status: 'pending' | 'success'; + detail?: string; +} + type StreamingMessage = | {id: number; type: 'user'; content: string} | {id: number; type: 'system'; content: string; isStreaming?: boolean; sources?: string[]} | { id: number; type: 'status'; - label: string; - isStreaming: boolean; - details: string; + status: 'pending' | 'success'; + steps: ExecutionStep[]; } | {id: number; type: 'card'; title: string; description: string; imageUrl: string} | {id: number; type: 'suggestions'; title: string; suggestions: string[]}; @@ -208,6 +216,44 @@ function CardMessage({ ); } +function StatusThreadItem({msg}: {msg: Extract}) { + let isStreaming = msg.status === 'pending'; + let lastStep = msg.steps[msg.steps.length - 1]; + let title = isStreaming + ? `${lastStep.label}…` + : msg.steps.length > 1 + ? `Completed ${msg.steps.length} steps` + : lastStep.label; + let announcement = isStreaming ? `${lastStep.label}…` : `${title} complete`; + // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? + // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we + // probably want to make that on a case by case basis + // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) + return ( + + + {title} + + + {msg.steps.map(step => ( + {step.detail}

+ ) + }> + {step.label} +
+ ))} +
+
+
+
+ ); +} + export function VirtualizedStreamingChat() { let [messages, setMessages] = useState( initialResponses as StreamingMessage[] @@ -226,38 +272,67 @@ export function VirtualizedStreamingChat() { {id: nextId.current++, type: 'user', content: prompt.toString()} ]); - function addTool(label: string, replaceStatus = false) { - setMessages(prev => - replaceStatus - ? [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - : [ - ...prev, - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - ); + // Starts a new grouped status message containing a single pending execution trace step. + function startToolGroup(label: string) { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + status: 'pending', + steps: [{id: nextId.current++, label, status: 'pending'}] + } + ]); } - function completeTool(details: string) { - setMessages(prev => - prev.map(m => - m.type === 'status' && m.isStreaming ? {...m, isStreaming: false, details} : m - ) - ); + // Adds a new step to the trailing status group if one is still open, otherwise starts a new group. + function addStep(label: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + let newStep: ExecutionStep = {id: nextId.current++, label, status: 'pending'}; + if (last?.type === 'status' && last.status === 'pending') { + return [ + ...prev.slice(0, -1), + { + ...last, + steps: [ + ...last.steps.slice(0, -1), + {...last.steps[last.steps.length - 1], status: 'success'}, + newStep + ] + } + ]; + } + return [ + ...prev, + {id: nextId.current++, type: 'status', status: 'pending', steps: [newStep]} + ]; + }); + } + + // Completes the last step of the trailing status group, optionally updating its label. + function completeStep(detail: string, label?: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + let steps = last.steps.slice(); + let step = steps[steps.length - 1]; + steps[steps.length - 1] = {...step, label: label ?? step.label, status: 'success', detail}; + return [...prev.slice(0, -1), {...last, steps}]; + }); + } + + // Marks the trailing status group as complete once all of its steps have finished. + function completeGroup() { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + return [...prev.slice(0, -1), {...last, status: 'success'}]; + }); } function streamText(content: string, sources?: string[]) { @@ -299,39 +374,25 @@ export function VirtualizedStreamingChat() { let timestamp = 0; let toolCallDuration = 1000; // Status added after short delay so user message announcement plays first - addTimeout( - () => { - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]); - }, - (timestamp += 500) - ); - addTimeout(() => addTool('Thinking', true), (timestamp += 500)); + addTimeout(() => startToolGroup('Thinking'), (timestamp += 500)); addTimeout( () => - completeTool( + completeStep( 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' ), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Loading tool'), (timestamp += 500)); + addTimeout(() => addStep('Loading tool'), (timestamp += 500)); addTimeout( - () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), + () => completeStep('Asset search tool loaded with access to the Hilton brand library.'), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Searching'), (timestamp += 500)); + addTimeout(() => addStep('Searching'), (timestamp += 500)); addTimeout( - () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), + () => completeStep('Found 15 assets matching the brand criteria across 3 campaigns.'), (timestamp += toolCallDuration) ); + addTimeout(() => completeGroup(), (timestamp += 200)); addTimeout( () => streamText( @@ -341,49 +402,30 @@ export function VirtualizedStreamingChat() { ); // then does searching, streaming more text, returning a card and sources - addTimeout(() => addTool('Searching'), (timestamp += 1000)); + addTimeout(() => startToolGroup('Searching'), (timestamp += 1000)); addTimeout( () => - completeTool('Identified additional brand materials related to the presentation context.'), + completeStep('Identified additional brand materials related to the presentation context.'), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Querying database'), (timestamp += 1000)); + addTimeout(() => addStep('Querying database'), (timestamp += 1000)); addTimeout( () => - completeTool( + completeStep( 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' ), (timestamp += toolCallDuration) ); + addTimeout(() => addStep('Generating response'), (timestamp += 500)); addTimeout( () => - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]), - (timestamp += 500) - ); - addTimeout( - () => - setMessages(prev => [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label: 'Response generated', - isStreaming: false, - details: - 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' - } - ]), + completeStep( + 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.', + 'Response generated' + ), (timestamp += 1000) ); + addTimeout(() => completeGroup(), (timestamp += 200)); let secondStreamContent = 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + 'layering in supporting visuals and data to reinforce the core message. The main themes ' + @@ -422,11 +464,19 @@ export function VirtualizedStreamingChat() { timeouts.current.forEach(clearTimeout); timeouts.current = []; setMessages(prev => - prev.map(m => - (m.type === 'system' || m.type === 'status') && m.isStreaming - ? {...m, isStreaming: false} - : m - ) + prev.map(m => { + if (m.type === 'system' && m.isStreaming) { + return {...m, isStreaming: false}; + } + if (m.type === 'status' && m.status === 'pending') { + return { + ...m, + status: 'success', + steps: m.steps.map((s, i) => (i === m.steps.length - 1 ? {...s, status: 'success'} : s)) + }; + } + return m; + }) ); setGenerating(false); } @@ -499,27 +549,7 @@ export function VirtualizedStreamingChat() { ); } if (msg.type === 'status') { - let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; - let title = msg.isStreaming ? `${msg.label}…` : msg.label; - // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? - // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we - // probably want to make that on a case by case basis - // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) - return ( - - - {title} - - {msg.details && ( -

{msg.details}

- )} -
-
-
- ); + return ; } if (msg.type === 'card') { return ( @@ -727,23 +757,7 @@ export function EmptyChat() { ); } if (msg.type === 'status') { - let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; - let title = msg.isStreaming ? `${msg.label}…` : msg.label; - return ( - - - {title} - - {msg.details && ( -

{msg.details}

- )} -
-
-
- ); + return ; } if (msg.type === 'card') { return ( diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index a6c77f79e4e..664fd1c7b3c 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -7,6 +7,8 @@ import {Image} from '@react-spectrum/s2/Image'; import {MenuItem} from '@react-spectrum/s2/Menu'; import { Chat, + ExecutionTrace, + ExecutionTraceItem, MessageFeedback, MessageSource, MessageSuggestion, @@ -106,15 +108,21 @@ let initialResponses = [ } ] as Message[]; +interface ExecutionStep { + id: number; + label: string; + status: 'pending' | 'success'; + detail?: string; +} + type StreamingMessage = | {id: number; type: 'user'; content: string} | {id: number; type: 'system'; content: string; isStreaming?: boolean; sources?: string[]} | { id: number; type: 'status'; - label: string; - isStreaming: boolean; - details: string; + status: 'pending' | 'success'; + steps: ExecutionStep[]; } | {id: number; type: 'card'; title: string; description: string; imageUrl: string} | {id: number; type: 'suggestions'; title: string; suggestions: string[]}; @@ -167,6 +175,44 @@ function CardMessage({ ); } +function StatusThreadItem({msg}: {msg: Extract}) { + let isStreaming = msg.status === 'pending'; + let lastStep = msg.steps[msg.steps.length - 1]; + let title = isStreaming + ? `${lastStep.label}…` + : msg.steps.length > 1 + ? `Completed ${msg.steps.length} steps` + : lastStep.label; + let announcement = isStreaming ? `${lastStep.label}…` : `${title} complete`; + // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? + // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we + // probably want to make that on a case by case basis + // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) + return ( + + + {title} + + + {msg.steps.map(step => ( + {step.detail}

+ ) + }> + {step.label} +
+ ))} +
+
+
+
+ ); +} + export function VirtualizedStreamingChat(props) { let [messages, setMessages] = useState( initialResponses as StreamingMessage[] @@ -184,38 +230,67 @@ export function VirtualizedStreamingChat(props) { {id: nextId.current++, type: 'user', content: prompt.toString()} ]); - function addTool(label: string, replaceStatus = false) { - setMessages(prev => - replaceStatus - ? [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - : [ - ...prev, - { - id: nextId.current++, - type: 'status', - label, - isStreaming: true, - details: '' - } - ] - ); + // Starts a new grouped status message containing a single pending execution trace step. + function startToolGroup(label: string) { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + status: 'pending', + steps: [{id: nextId.current++, label, status: 'pending'}] + } + ]); } - function completeTool(details: string) { - setMessages(prev => - prev.map(m => - m.type === 'status' && m.isStreaming ? {...m, isStreaming: false, details} : m - ) - ); + // Adds a new step to the trailing status group if one is still open, otherwise starts a new group. + function addStep(label: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + let newStep: ExecutionStep = {id: nextId.current++, label, status: 'pending'}; + if (last?.type === 'status' && last.status === 'pending') { + return [ + ...prev.slice(0, -1), + { + ...last, + steps: [ + ...last.steps.slice(0, -1), + {...last.steps[last.steps.length - 1], status: 'success'}, + newStep + ] + } + ]; + } + return [ + ...prev, + {id: nextId.current++, type: 'status', status: 'pending', steps: [newStep]} + ]; + }); + } + + // Completes the last step of the trailing status group, optionally updating its label. + function completeStep(detail: string, label?: string) { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + let steps = last.steps.slice(); + let step = steps[steps.length - 1]; + steps[steps.length - 1] = {...step, label: label ?? step.label, status: 'success', detail}; + return [...prev.slice(0, -1), {...last, steps}]; + }); + } + + // Marks the trailing status group as complete once all of its steps have finished. + function completeGroup() { + setMessages(prev => { + let last = prev[prev.length - 1]; + if (last?.type !== 'status') { + return prev; + } + return [...prev.slice(0, -1), {...last, status: 'success'}]; + }); } function streamText(content: string, sources?: string[]) { @@ -257,39 +332,25 @@ export function VirtualizedStreamingChat(props) { let timestamp = 0; let toolCallDuration = 1000; // Status added after short delay so user message announcement plays first - addTimeout( - () => { - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]); - }, - (timestamp += 500) - ); - addTimeout(() => addTool('Thinking', true), (timestamp += 500)); + addTimeout(() => startToolGroup('Thinking'), (timestamp += 500)); addTimeout( () => - completeTool( + completeStep( 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' ), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Loading tool'), (timestamp += 500)); + addTimeout(() => addStep('Loading tool'), (timestamp += 500)); addTimeout( - () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), + () => completeStep('Asset search tool loaded with access to the Hilton brand library.'), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Searching'), (timestamp += 500)); + addTimeout(() => addStep('Searching'), (timestamp += 500)); addTimeout( - () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), + () => completeStep('Found 15 assets matching the brand criteria across 3 campaigns.'), (timestamp += toolCallDuration) ); + addTimeout(() => completeGroup(), (timestamp += 200)); addTimeout( () => streamText( @@ -299,49 +360,30 @@ export function VirtualizedStreamingChat(props) { ); // then does searching, streaming more text, returning a card and sources - addTimeout(() => addTool('Searching'), (timestamp += 1000)); + addTimeout(() => startToolGroup('Searching'), (timestamp += 1000)); addTimeout( () => - completeTool('Identified additional brand materials related to the presentation context.'), + completeStep('Identified additional brand materials related to the presentation context.'), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Querying database'), (timestamp += 1000)); + addTimeout(() => addStep('Querying database'), (timestamp += 1000)); addTimeout( () => - completeTool( + completeStep( 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' ), (timestamp += toolCallDuration) ); + addTimeout(() => addStep('Generating response'), (timestamp += 500)); addTimeout( () => - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'status', - label: 'Generating response', - isStreaming: true, - details: '' - } - ]), - (timestamp += 500) - ); - addTimeout( - () => - setMessages(prev => [ - ...prev.slice(0, -1), - { - id: nextId.current++, - type: 'status', - label: 'Response generated', - isStreaming: false, - details: - 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' - } - ]), + completeStep( + 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.', + 'Response generated' + ), (timestamp += 1000) ); + addTimeout(() => completeGroup(), (timestamp += 200)); let secondStreamContent = 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + 'layering in supporting visuals and data to reinforce the core message. The main themes ' + @@ -442,27 +484,7 @@ export function VirtualizedStreamingChat(props) { ); } if (msg.type === 'status') { - let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; - let title = msg.isStreaming ? `${msg.label}…` : msg.label; - // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? - // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we - // probably want to make that on a case by case basis - // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) - return ( - - - {title} - - {msg.details && ( -

{msg.details}

- )} -
-
-
- ); + return ; } if (msg.type === 'card') { return ( From c0aaca36df6da3cf17ce8b209988483077e79d7f Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 12:34:52 -0500 Subject: [PATCH 07/27] add installation section, alpha badge, API section, PropTables, and rename headings to prevent anchor link collision --- .../dev/s2-docs/pages/s2/ai-components.mdx | 164 +++++++++++++++++- 1 file changed, 162 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 92737ef6ba9..8e70ebfb4d5 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -1,16 +1,27 @@ import {Layout} from '../../src/Layout'; +import {InstallCommand} from '../../src/InstallCommand'; import {InlineAlert, Heading, Content} from '@react-spectrum/s2'; +import {VersionBadge} from '../../src/VersionBadge'; export default Layout; +import docs from 'docs:@react-spectrum/ai'; + export const section = 'Guides'; export const tags = ['spectrum', 'ai']; export const description = 'Build AI-powered experiences with prompts, messages, suggestions, attachments, and voice input.'; # AI Components + React Spectrum provides components for building AI-powered experiences, including prompts, messages, suggestions, attachments, and voice input. -## Prompt field +## Installation + +AI components are published as a separate package from `@react-spectrum/s2`. + + + +## Prompt fields Use `PromptField` as the foundation for allowing the user to submit a prompt. It manages an editable sequence of text and tokens, while `PromptTokenField` renders the input and `PromptFieldToolbar` contains its actions. @@ -166,7 +177,7 @@ function PromptAttachments() { } ``` -## Chat +## Chat threads Compose a conversation from `Chat`, `Thread`, and message components. The thread can be driven by a collection as messages arrive from your application. @@ -549,3 +560,152 @@ function Example(args) { ); } ``` + +## API + +### Prompt field + +```tsx links={{PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar', PromptFieldSubmitButton: '#promptfieldsubmitbutton', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptToken: '#prompttoken', PromptFieldAttachmentList: '#promptfieldattachmentlist', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem'}} + + + + {token => } + + + + + + + + + + + + +``` + +#### PromptField + + + +#### PromptTokenField + + + +#### PromptToken + + + +#### PromptFieldAttachmentList + + + +#### PromptFieldToolbar + + + +#### PromptFieldSubmitButton + + + +#### PromptFieldVoiceButton + + + +#### InsertMenuButton + + + +#### AttachFileMenuItem + + + +#### InsertTextMenuItem + + + +#### InsertTokenMenuItem + + + +#### CommandMenuItem + + + +### Suggestions + +```tsx links={{MessageSuggestionList: '#messagesuggestionlist', MessageSuggestion: '#messagesuggestion'}} + + + +``` + +#### MessageSuggestionList + + + +#### MessageSuggestion + + + +### Attachments + +```tsx links={{Attachment: '#attachment', AttachmentList: '#attachmentlist', AttachmentPreview: '#attachmentpreview'}} + + {attachment => ( + + + + )} + +``` + +#### Attachment + + + +#### AttachmentList + + + +#### AttachmentPreview + + + +### Chat + +```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', ThreadScrollButton: '#threadscrollbutton', UserMessage: '#usermessage'}} + + + + or assistant content + + + + + +``` + +#### Chat + + + +#### Thread + + + +#### ThreadItem + + + +#### ThreadLoadMoreItem + + + +#### ThreadScrollButton + + + +#### UserMessage + + From 7d5c04254517164dbe6ab250d34b28262aed084f Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 12:58:13 -0500 Subject: [PATCH 08/27] typescript fixes --- .../@react-spectrum/ai/src/useVoiceInput.ts | 28 +++++++++++++++++++ .../dev/s2-docs/pages/s2/ai-components.mdx | 13 ++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/ai/src/useVoiceInput.ts b/packages/@react-spectrum/ai/src/useVoiceInput.ts index 3030bcaa8c7..be45583cd0e 100644 --- a/packages/@react-spectrum/ai/src/useVoiceInput.ts +++ b/packages/@react-spectrum/ai/src/useVoiceInput.ts @@ -22,6 +22,34 @@ import { } from 'react'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; +declare global { + interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + onstart: ((ev: Event) => void) | null; + onresult: ((ev: SpeechRecognitionEvent) => void) | null; + onerror: ((ev: {error: string}) => void) | null; + onend: ((ev: Event) => void) | null; + abort(): void; + start(): void; + stop(): void; + } + + interface SpeechRecognitionConstructor { + new (): SpeechRecognition; + } + + interface Window { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; + } + + interface Navigator { + userAgentData?: {brands: Array<{brand: string; version: string}>; platform?: string}; + } +} + /** * Chromium brands whose Web Speech backend is known to work. Other Chromium * forks (Arc, Brave, Edge) expose the API but route to a backend that does not diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 8e70ebfb4d5..b372f71c7df 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -33,12 +33,11 @@ import { PromptFieldSubmitButton, PromptFieldToolbar, PromptFieldValue, - PromptTokenField, - TokenFieldValue + PromptTokenField } from '@react-spectrum/ai'; function BasicPrompt() { - let [value, setValue] = useState(() => new PromptFieldValue([])); + let [value, setValue] = useState(() => new PromptFieldValue([])); return ( (() => new PromptFieldValue([])); + let [value, setValue] = useState(() => new PromptFieldValue([])); return (
@@ -249,7 +247,6 @@ This example combines prompt suggestions, token completions, attachments, toolba import {useState, useRef} from 'react'; import { AttachFileMenuItem, - CommandMenuItem, InsertTextMenuItem, InsertTokenMenuItem, InsertMenuButton, @@ -273,7 +270,7 @@ import {getIcon, prompts, slashCommands, objects, renderCompletions, type Upload import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import * as data from '@react-spectrum/ai/loader'; -import {Collection, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; +import {Collection, Content, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; import Data from '@react-spectrum/s2/icons/Data'; import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; From 8064621aec1a211ace8df8d504c92bec6dabd6b7 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 13:24:31 -0500 Subject: [PATCH 09/27] fix lint --- .../@react-spectrum/ai/src/useVoiceInput.ts | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/@react-spectrum/ai/src/useVoiceInput.ts b/packages/@react-spectrum/ai/src/useVoiceInput.ts index be45583cd0e..6ac69484ba2 100644 --- a/packages/@react-spectrum/ai/src/useVoiceInput.ts +++ b/packages/@react-spectrum/ai/src/useVoiceInput.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +/// + import { Dispatch, RefObject, @@ -22,34 +24,6 @@ import { } from 'react'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; -declare global { - interface SpeechRecognition extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - onstart: ((ev: Event) => void) | null; - onresult: ((ev: SpeechRecognitionEvent) => void) | null; - onerror: ((ev: {error: string}) => void) | null; - onend: ((ev: Event) => void) | null; - abort(): void; - start(): void; - stop(): void; - } - - interface SpeechRecognitionConstructor { - new (): SpeechRecognition; - } - - interface Window { - SpeechRecognition?: SpeechRecognitionConstructor; - webkitSpeechRecognition?: SpeechRecognitionConstructor; - } - - interface Navigator { - userAgentData?: {brands: Array<{brand: string; version: string}>; platform?: string}; - } -} - /** * Chromium brands whose Web Speech backend is known to work. Other Chromium * forks (Arc, Brave, Edge) expose the API but route to a backend that does not From 3e48be18d2ac82e2f0ed10b6cd57257bfb86e684 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 31 Aug 2026 14:16:49 -0500 Subject: [PATCH 10/27] add missing JSDoc descriptions --- packages/@react-spectrum/ai/src/AIButton.tsx | 3 +++ packages/@react-spectrum/ai/src/AttachmentList.tsx | 3 +++ packages/@react-spectrum/ai/src/Chat.tsx | 3 +++ packages/@react-spectrum/ai/src/PromptField.tsx | 4 ++++ 4 files changed, 13 insertions(+) diff --git a/packages/@react-spectrum/ai/src/AIButton.tsx b/packages/@react-spectrum/ai/src/AIButton.tsx index 234c51316d3..242b11df4ab 100644 --- a/packages/@react-spectrum/ai/src/AIButton.tsx +++ b/packages/@react-spectrum/ai/src/AIButton.tsx @@ -236,6 +236,9 @@ const bg = css(` } `); +/** + * An AIButton triggers an AI-powered action with a customizable branded appearance. + */ export function AIButton({size = 'M', brandColor, children, ...otherProps}: AIButtonProps) { let ref = useRef(null); return ( diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 6938a824c5e..bb7d97585c6 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -309,6 +309,9 @@ export interface AttachmentListProps styles?: StyleString; } +/** + * An AttachmentList displays removable file attachments with previews and upload states. + */ export const AttachmentList = (forwardRef as forwardRefType)(function AttachmentList( props: AttachmentListProps, ref: DOMRef diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 3789d006628..8c1b354f22b 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -120,6 +120,9 @@ export interface ChatProps { children?: ReactNode; } +/** + * A Chat displays an accessible, streaming conversation between a user and an AI. + */ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( props: ChatProps, ref: DOMRef diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 2211996278f..8ee1defa9da 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -256,6 +256,10 @@ function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { }); } +/** + * A PromptField allows users to compose and submit prompts containing text, tokens, and + * attachments. + */ export const PromptField = forwardRef(function PromptField( props: PromptFieldProps, ref: FocusableRef From 487c037ff73b7725c46c5f6447af89422ac7bfff Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 31 Aug 2026 14:41:40 -0700 Subject: [PATCH 11/27] small styling fixes, make full example stream --- .../pages/s2/ai-component-helpers/chat.tsx | 2 +- .../dev/s2-docs/pages/s2/ai-components.mdx | 27 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index 664fd1c7b3c..e6ef6d1117e 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -522,7 +522,7 @@ export function VirtualizedStreamingChat(props) { }}
- {props.children} + {typeof props.children === 'function' ? props.children(handleSend) : props.children}
); diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index b372f71c7df..1a338468957 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -49,7 +49,9 @@ function BasicPrompt() { }}> - +
+ +
); @@ -112,7 +114,9 @@ function PromptSuggestions() { } placeholder="Ask about @customers" /> - +
+ +
@@ -167,8 +171,10 @@ function PromptAttachments() { Summarize image - - +
+ + +
); @@ -186,7 +192,9 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; let messages = [ {id: 1, type: 'user', text: 'Summarize the campaign results.'}, - {id: 2, type: 'assistant', text: 'Engagement increased 18% this month, led by email and social.'} + {id: 2, type: 'assistant', text: 'Engagement increased 18% this month, led by email and social.'}, + {id: 3, type: 'user', text: 'Which channel performed best?'}, + {id: 4, type: 'assistant', text: 'Email drove the most conversions, with social close behind.'} ]; function BasicChat() { @@ -199,6 +207,7 @@ function BasicChat() { width: 'full', overflow: 'hidden', boxSizing: 'border-box', + paddingX: 4, minWidth: 0 })}>
@@ -262,7 +270,7 @@ import { PromptToken, PromptTokenField, TokenFieldValue, - MessageSuggestion, + MessageSuggestion, MessageSuggestionList } from '@react-spectrum/ai'; import {type FocusableRefValue} from '@react-types/shared'; @@ -320,10 +328,11 @@ function Example(args) { return (
+ {onSend => (
{/*- begin highlight -*/} - {prompts.map((prompt, i) => ( + {prompts.slice(0, 2).map((prompt, i) => ( { @@ -375,6 +384,7 @@ function Example(args) { onSubmit={prompt => { historyRef.current = [...historyRef.current, prompt]; historyIndexRef.current = -1; + onSend(prompt); setValue(new PromptFieldValue([])); setAttachments([]); setAttachmentState(new Map()); @@ -552,6 +562,7 @@ function Example(args) { {/*- end highlight -*/}
+ )}
); From ea0f4eb4c79a317f29b7b1106f4518317b7d3803 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 31 Aug 2026 16:38:39 -0700 Subject: [PATCH 12/27] simplify and clean up complete docs example --- .../pages/s2/ai-component-helpers/chat.tsx | 362 +++++------------- .../s2/ai-component-helpers/promptfield.tsx | 149 ++----- .../dev/s2-docs/pages/s2/ai-components.mdx | 141 ++----- 3 files changed, 169 insertions(+), 483 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index e6ef6d1117e..8f73aed49fa 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -1,23 +1,17 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; -import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; -import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; -import {Content} from '@react-spectrum/s2/Content'; -import {Image} from '@react-spectrum/s2/Image'; -import {MenuItem} from '@react-spectrum/s2/Menu'; +import {getIcon} from './promptfield'; import { Chat, ExecutionTrace, ExecutionTraceItem, MessageFeedback, - MessageSource, MessageSuggestion, MessageSuggestionList, ResponseStatus, ResponseStatusPanel, ResponseStatusTitle, - SourceList, - SourceListItem, Thread, ThreadItem, ThreadScrollButton, @@ -25,88 +19,71 @@ import { UserMessage } from '@react-spectrum/ai'; import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; -import {ReactNode, useEffect, useRef, useState} from 'react'; +import {ReactNode, useEffect, useMemo, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Text} from '@react-spectrum/s2/Text'; -type Message = - | {id: number; type: 'user' | 'system'; content: string} - | {id: number; type: 'status'; status: 'pending' | 'complete'}; +let suggestionToken = style({ + outlineStyle: { + default: 'solid', + isPlaceholder: 'dashed' + }, + outlineWidth: 1, + outlineColor: { + default: 'transparent-overlay-1000/20', + isPlaceholder: 'transparent-overlay-1000/40' + }, + outlineOffset: -1, + borderRadius: 'pill', + paddingX: 8, + paddingY: 0, + fontSize: 'ui', + display: 'inline-flex', + alignItems: 'baseline', + gap: 4, + verticalAlign: 'baseline' +}); + +function SuggestionLabel({value}: {value: TokenFieldValue}) { + return ( + <> + {value.segments.map((seg, i) => + seg.type === 'token' ? ( + + {getIcon(seg) && {getIcon(seg)}} + {seg.text} + + ) : ( + seg.text + ) + )} + + ); +} let initialResponses = [ { id: 0, type: 'user', - content: - "I'm launching a new product next month—a sustainable, affordable athletic wear line targeting Gen Z. We have a limited budget and need to figure out our go-to-market strategy. Where do we even start?" + content: 'Can you help me plan a short trip to the mountains this weekend?' }, { id: 1, type: 'system', content: - "Exciting launch! A few things to clarify so I give you relevant guidance: (1) What's your total marketing budget for the launch month? (2) Do you already have an email list, social following, or any existing audience? (3) Who's your main competitor, and what are they doing? (4) Is this DTC (direct-to-consumer) or are you selling through retailers? These will shape whether we focus on paid ads, influencers, organic content, or a mix." + 'Sure! How many days do you have, and do you prefer hiking, skiing, or just relaxing?' }, { id: 2, type: 'user', - content: - "We have about $15,000 for the first month. We're DTC only, launching on our own website. We have a small email list of about 2,000 people from a waitlist, and basically no social following yet. Our main competitor is Reformation, but they're way more established. We want to emphasize our sustainability angle and lower price point." + content: 'Two days, and I want a mix of hiking and relaxing.' }, { id: 3, type: 'system', content: - "Okay, $15K is modest but workable for DTC. Here's my initial take: Since you're starting from zero on social and have a small email list, I'd suggest a three-part approach: (1) Allocate ~$8K to highly targeted paid ads (TikTok and Instagram) reaching eco-conscious Gen Z with strong creative, (2) Use $3K for micro-influencer partnerships (people with 10K-100K followers in the sustainability space—cheaper but authentic), (3) Keep $4K for email and organic content. Before we dive deeper—what's your product's main hook? Is it the materials, the price, the designs, or the transparency of your supply chain?" - }, - { - id: 4, - type: 'user', - content: - "The main hook is that we use recycled ocean plastic and sell at 40% lower prices than Reformation. Our supply chain is also fully transparent. We've got 15 styles launching across tops, shorts, and leggings." - }, - { - id: 5, - type: 'system', - content: - "Perfect—that's a strong, differentiated message. Here's what I'd focus on: The ocean plastic angle is visually compelling and emotionally resonant with Gen Z; the price-to-quality ratio is your competitive advantage against Reformation. For your first month, I'd recommend this campaign narrative: 'High performance, zero guilt, actual prices.' Now, a strategic question: Do you want to go all-in on one or two channels, or spread across TikTok, Instagram, and YouTube? Gen Z is on TikTok and Instagram, but each requires different content styles. What does your creative team feel most confident producing?" - }, - { - id: 6, - type: 'user', - content: - "We can handle both TikTok and Instagram, but honestly TikTok feels more authentic for our brand. We have some behind-the-scenes footage of our production process and some early customer testimonials. Instagram we'd probably do more polished product shots." - }, - { - id: 7, - type: 'system', - content: - "Smart instinct—TikTok is perfect for you because behind-the-scenes and authenticity is the content style there, and sustainability messaging performs really well. That said, here's my suggestion: (1) Use TikTok primarily for awareness and virality—behind-the-scenes, sustainability stories, affordability messaging. Spend $5K there. (2) Use Instagram for conversion—polished product shots, customer testimonials, sustainability proof. Spend $3K there. (3) With your micro-influencers ($3K), focus on TikTok creators first since that's where your audience is. One question though: Do you have a clear conversion path ready? Like, when someone clicks your ad, what's the landing page experience? Are you doing a discount code to incentivize first purchase?" - }, - { - id: 8, - type: 'user', - content: - "We have the website ready, but we haven't thought about a launch discount. Should we do one? Won't that hurt our perceived value?" - }, - { - id: 9, - type: 'system', - content: - "Great question—common concern. For a DTC launch with limited awareness, I'd actually recommend a strategic discount, not a deep one. Something like 15-20% off for the first 48 hours, with messaging like 'Early founder pricing' or 'Launch week exclusive.' Why? It creates urgency, lowers the barrier for first-time buyers trying an unknown brand, and you get email addresses and reviews to leverage later. You're not training people to wait for sales—you're using urgency to convert. Pair it with clear value messaging (ocean plastic, transparent supply chain, high quality) so it doesn't feel cheap. Also, include a referral incentive—Gen Z shares—maybe $10 off for them and the referee. This turns customers into advocates. Does that strategy feel right for your brand?" - }, - { - id: 10, - type: 'user', - content: - 'That makes sense. Before we move forward, can you summarize the full plan and tell me what could go wrong?' - }, - { - id: 11, - type: 'system', - content: - "Absolutely. Your Launch Plan: $8K paid ads (TikTok $5K, Instagram $3K) with behind-the-scenes and product content; $3K micro-influencer partnerships on TikTok; $4K allocated to email nurture and organic/referral growth. 15-20% launch discount for 48 hours. What could go wrong: (1) Creative underperforms—test multiple ad versions early and kill losers fast, (2) Website crashes or checkout breaks—stress test before launch, (3) Influencers don't deliver—vet their audiences carefully and look for engagement rates, not just follower count, (4) You don't have enough inventory—make sure supply matches demand projections, (5) Customer acquisition cost exceeds your margins—monitor CAC closely in week one and adjust spending. My advice: Launch on a Tuesday or Wednesday so you have time to optimize before the weekend. Track everything obsessively in the first 72 hours—CAC, conversion rate, email signup rate. Be ready to shift budget if one channel is outperforming. Want me to help you build a monitoring dashboard or refine your email launch sequence next?" + 'Day one: a moderate morning hike with a scenic overlook, then lunch in town. Day two: a slow start, a short walk, and time to unwind before heading back.' } -] as Message[]; +]; interface ExecutionStep { id: number; @@ -116,64 +93,15 @@ interface ExecutionStep { } type StreamingMessage = - | {id: number; type: 'user'; content: string} - | {id: number; type: 'system'; content: string; isStreaming?: boolean; sources?: string[]} + | {id: number | string; type: 'user'; content: string} + | {id: number | string; type: 'system'; content: string; isStreaming?: boolean} | { - id: number; + id: number | string; type: 'status'; status: 'pending' | 'success'; steps: ExecutionStep[]; } - | {id: number; type: 'card'; title: string; description: string; imageUrl: string} - | {id: number; type: 'suggestions'; title: string; suggestions: string[]}; - -let MOCK_SOURCES = [ - 'Hilton brand email — Q1 campaign 2026', - 'Market research — hospitality trends 2025', - 'User research — loyalty programme survey' -]; - -let MOCK_SUGGESTIONS = [ - 'Suggest a presentation structure', - 'What other assets might be relevant?', - 'Summarize the key themes' -]; - -let MOCK_CARD = { - title: 'Desert Sunset', - description: 'PNG • 2/3/2024', - imageUrl: - 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' -}; - -function CardMessage({ - title, - description, - imageUrl -}: { - title: string; - description: string; - imageUrl: string; -}) { - return ( - - - - - - - {title} - - Edit - Share - Delete - - {description} - - - - ); -} + | {id: number | string; type: 'suggestions'; suggestions: TokenFieldValue[]}; function StatusThreadItem({msg}: {msg: Extract}) { let isStreaming = msg.status === 'pending'; @@ -184,10 +112,6 @@ function StatusThreadItem({msg}: {msg: Extract @@ -213,18 +137,25 @@ function StatusThreadItem({msg}: {msg: Extract void) => ReactNode; + /** Suggestions shown at the end of the thread. Hidden while a response is streaming in. */ + suggestions?: TokenFieldValue[]; + onSelectSuggestion?: (suggestion: TokenFieldValue) => void; +} + +export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { + let {children, suggestions, onSelectSuggestion} = props; let [messages, setMessages] = useState( initialResponses as StreamingMessage[] ); let nextId = useRef(initialResponses.length); let [isGenerating, setGenerating] = useState(false); let timeouts = useRef([]); - let followUpMessage = useRef(null); function handleSend(prompt: TokenFieldValue) { setGenerating(true); - // user message added first so its announcement plays before + // user message added first so its announcement plays before the status updates setMessages(prev => [ ...prev, {id: nextId.current++, type: 'user', content: prompt.toString()} @@ -268,8 +199,8 @@ export function VirtualizedStreamingChat(props) { }); } - // Completes the last step of the trailing status group, optionally updating its label. - function completeStep(detail: string, label?: string) { + // Completes the last step of the trailing status group. + function completeStep(detail: string) { setMessages(prev => { let last = prev[prev.length - 1]; if (last?.type !== 'status') { @@ -277,7 +208,7 @@ export function VirtualizedStreamingChat(props) { } let steps = last.steps.slice(); let step = steps[steps.length - 1]; - steps[steps.length - 1] = {...step, label: label ?? step.label, status: 'success', detail}; + steps[steps.length - 1] = {...step, status: 'success', detail}; return [...prev.slice(0, -1), {...last, steps}]; }); } @@ -293,7 +224,7 @@ export function VirtualizedStreamingChat(props) { }); } - function streamText(content: string, sources?: string[]) { + function streamText(content: string) { setMessages(prev => [ ...prev, {id: nextId.current++, type: 'system', content: '', isStreaming: true} @@ -307,12 +238,7 @@ export function VirtualizedStreamingChat(props) { setMessages(prev => prev.map(m => m.type === 'system' && m.isStreaming - ? { - ...m, - content: accumulated, - isStreaming: !isLastToken, - ...(isLastToken && sources ? {sources} : {}) - } + ? {...m, content: accumulated, isStreaming: !isLastToken} : m ) ); @@ -326,99 +252,55 @@ export function VirtualizedStreamingChat(props) { return timeout; }; - // TODO: these durations are quite generous in order to accomodate for announcements, but realistically it might be - // faster and thus the announcements will get cut off even with polite... - // first batch, does tool calls with text response let timestamp = 0; let toolCallDuration = 1000; - // Status added after short delay so user message announcement plays first - addTimeout(() => startToolGroup('Thinking'), (timestamp += 500)); - addTimeout( - () => - completeStep( - 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' - ), - (timestamp += toolCallDuration) - ); - addTimeout(() => addStep('Loading tool'), (timestamp += 500)); + // Status added after a short delay so the user message announcement plays first. + addTimeout(() => startToolGroup('Searching Yelp'), (timestamp += 500)); addTimeout( - () => completeStep('Asset search tool loaded with access to the Hilton brand library.'), + () => completeStep('Found 12 restaurants near the trailhead.'), (timestamp += toolCallDuration) ); - addTimeout(() => addStep('Searching'), (timestamp += 500)); + addTimeout(() => addStep('Searching Google Maps'), (timestamp += 500)); addTimeout( - () => completeStep('Found 15 assets matching the brand criteria across 3 campaigns.'), + () => completeStep('Compared ratings and walking distances.'), (timestamp += toolCallDuration) ); - addTimeout(() => completeGroup(), (timestamp += 200)); + addTimeout(() => addStep('Searching TripAdvisor'), (timestamp += 500)); addTimeout( - () => - streamText( - 'I found some relevant assets that match your request. Let me pull up the details.' - ), - (timestamp += 500) - ); - - // then does searching, streaming more text, returning a card and sources - addTimeout(() => startToolGroup('Searching'), (timestamp += 1000)); - addTimeout( - () => - completeStep('Identified additional brand materials related to the presentation context.'), + () => completeStep('Checked recent reviews for the top matches.'), (timestamp += toolCallDuration) ); - addTimeout(() => addStep('Querying database'), (timestamp += 1000)); + addTimeout(() => addStep('Filtering by distance'), (timestamp += 500)); addTimeout( - () => - completeStep( - 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' - ), + () => completeStep('Narrowed the list down to places within range.'), (timestamp += toolCallDuration) ); - addTimeout(() => addStep('Generating response'), (timestamp += 500)); - addTimeout( - () => - completeStep( - 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.', - 'Response generated' - ), - (timestamp += 1000) - ); addTimeout(() => completeGroup(), (timestamp += 200)); - let secondStreamContent = - 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + - 'layering in supporting visuals and data to reinforce the core message. The main themes ' + - 'revolve around brand consistency, audience engagement, and clear calls to action.'; - addTimeout(() => streamText(secondStreamContent, MOCK_SOURCES), (timestamp += 500)); - let streamEndTimestamp = timestamp + (secondStreamContent.split(' ').length - 1) * 80 + 500; - addTimeout(() => { - setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); - }, streamEndTimestamp); - addTimeout(() => { - setMessages(prev => [ - ...prev, - { - id: nextId.current++, - type: 'suggestions', - title: 'Suggested follow-ups', - suggestions: MOCK_SUGGESTIONS - } - ]); - setGenerating(false); - }, streamEndTimestamp + 1000); + let replyContent = + 'A few good options within range: Trailhead Café for a quick bite, Base Camp Diner for ' + + 'something heartier, and Riverside Grill if you want a sit-down meal.'; + addTimeout(() => streamText(replyContent), (timestamp += 500)); + + let streamEndTimestamp = timestamp + (replyContent.split(' ').length - 1) * 80 + 500; + addTimeout(() => setGenerating(false), streamEndTimestamp); } - useEffect(() => { - if (!isGenerating && followUpMessage.current) { - let followup = followUpMessage.current; - followUpMessage.current = null; - handleSend(followup); + useEffect( + () => () => { + timeouts.current.forEach(clearTimeout); + }, + [] + ); + + let items = useMemo(() => { + if (isGenerating || !suggestions) { + return messages; } - }, [isGenerating]); + return [...messages, {id: 'suggestions', type: 'suggestions', suggestions}]; + }, [messages, isGenerating, suggestions]); return ( - // TODO: these extra div wrappers would need to be implemented by the RAC user, maybe we can internalize some more? - // of particular note is the scroll button. Same for the other styles
{(msg: StreamingMessage) => { if (msg.type === 'user') { - // TODO: probably want ThreadItem to be a part of UserMessage? return ( ; } - if (msg.type === 'card') { - return ( - - ); - } if (msg.type === 'suggestions') { - // TODO: probably should have ThreadItem auto wrap MessageSuggestionList as well - // but this one I could see perhaps being a standalone component to be used outside of thread return ( - - + + {msg.suggestions.map((s, i) => ( - {s} + onSelectSuggestion?.(s)}> + + ))} ); } return ( - +

{msg.content || ''}

{!msg.isStreaming && } -
+
); }}
- {typeof props.children === 'function' ? props.children(handleSend) : props.children} + {children(handleSend)} ); } - -function SystemMessage({ - children, - textValue = ' ', - isStreaming, - sources -}: { - children: ReactNode; - textValue?: string; - isStreaming?: boolean; - sources?: string[]; -}) { - return ( - - {children} - {sources && sources.length > 0 && ( - - - {sources.map((s, i) => ( - - {s} - - ))} - - - )} - - ); -} diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx index 2b059df02a6..9937756d0bf 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx @@ -1,41 +1,32 @@ import { CommandMenuItem, - InsertTextMenuItem, InsertTokenMenuItem, PromptFieldTokenValue, PromptFieldValue } from '@react-spectrum/ai'; -import Brand from '@react-spectrum/s2/icons/Brand'; -import {Header, Heading, MenuItem, MenuSection, Text} from '@react-spectrum/s2/Menu'; +import FileText from '@react-spectrum/s2/icons/FileText'; +import {Header, Heading, MenuSection, Text} from '@react-spectrum/s2/Menu'; import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; import LinkIcon from '@react-spectrum/s2/icons/Link'; import Plugin from '@react-spectrum/s2/icons/Plugin'; +import Project from '@react-spectrum/s2/icons/Project'; import Prompt from '@react-spectrum/s2/icons/Prompt'; -import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; import {TokenSegment} from 'react-stately'; import UserGroup from '@react-spectrum/s2/icons/UserGroup'; export const slashCommands = [ - { - command: '/audience-explainer', - kind: 'skill', - description: 'Explain an AEP audience in english' - }, - {command: '/btw', kind: 'command', description: 'Ask a side question'}, - {command: '/clear', kind: 'command', description: 'Clear the context'}, - {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, - {command: '/dataset-usage', kind: 'skill', description: 'Explain how to use a dataset'}, - {command: '/feedback', kind: 'command', description: 'Submit feedback'}, - {command: '/plan', kind: 'command', description: 'Create a plan before executing'}, - {command: '/visual-artifact', kind: 'skill', description: 'Generate a chart or graph'} + {command: '/clear', kind: 'command', description: 'Clear the conversation'}, + {command: '/compact', kind: 'command', description: 'Summarize the conversation so far'}, + {command: '/docx', kind: 'skill', description: 'Create or edit a Word document'}, + {command: '/pptx', kind: 'skill', description: 'Create a slide deck'} ]; const icons = { command: , skill: , - audience: , - campaign: , - journey: , + person: , + document: , + project: , url: } as const; @@ -54,36 +45,29 @@ export function getIcon(token: TokenSegment) { export const objects = [ { - section: 'Audiences', - type: 'audience', + section: 'People', + type: 'person', items: [ - {kind: 'audience', title: 'New Customers'}, - {kind: 'audience', title: 'Returning Customers'}, - {kind: 'audience', title: 'Loyal Customers'}, - {kind: 'audience', title: 'High-Value Customers'}, - {kind: 'audience', title: 'Low-Value Customers'} + {kind: 'person', title: 'Alex Rivera'}, + {kind: 'person', title: 'Jamie Chen'}, + {kind: 'person', title: 'Morgan Taylor'} ] }, { - section: 'Campaigns', - type: 'campaign', + section: 'Documents', + type: 'document', items: [ - {kind: 'campaign', title: 'Spring Launch 2026'}, - {kind: 'campaign', title: 'Holiday Cheer'}, - {kind: 'campaign', title: 'Back to School'}, - {kind: 'campaign', title: 'Summer Adventure'}, - {kind: 'campaign', title: 'Tech Trends Expo'} + {kind: 'document', title: 'Project plan'}, + {kind: 'document', title: 'Meeting notes'}, + {kind: 'document', title: 'Research summary'} ] }, { - section: 'Journeys', - type: 'journey', + section: 'Projects', + type: 'project', items: [ - {kind: 'journey', title: 'Welcome Flow'}, - {kind: 'journey', title: 'Abandoned Cart Recovery'}, - {kind: 'journey', title: 'Post-Purchase Follow-up'}, - {kind: 'journey', title: 'Re-engagement Campaign'}, - {kind: 'journey', title: 'Birthday Surprise Journey'} + {kind: 'project', title: 'Website redesign'}, + {kind: 'project', title: 'Mobile app launch'} ] } ]; @@ -103,25 +87,15 @@ export function renderCompletions(filterValue: string, callbacks?: CompletionCal (callbacks?.valueType ? item.kind === callbacks.valueType : true) ) .map(item => - item.command === '/clear' ? ( - - - {item.command} - {item.description} - - ) : item.command === '/compact' ? ( - + item.kind === 'command' ? ( + {item.command} {item.description} - ) : item.command === '/feedback' || item.command === '/btw' ? ( - // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing - - - {item.command} - {item.description} - ) : ( - {item.kind === 'skill' ? : } + {item.command} {item.description} @@ -179,60 +153,11 @@ export interface UploadState { progress?: number; } -function atEnd(v: PromptFieldValue) { - let segs = v.segments; - return {index: segs.length - 1, offset: segs[segs.length - 1].text.length}; -} - -let prompt1 = new PromptFieldValue([ - {type: 'text', text: 'Analyze '}, - { - type: 'token', - text: 'New Customers', - value: {type: 'custom', anchor: '@', valueType: 'audience', data: {title: 'New Customers'}} - }, - {type: 'text', text: ' and suggest targeting strategies'} -]); - -let prompt2 = new PromptFieldValue([ - {type: 'text', text: 'Write a brief for '}, - { - type: 'token', - text: 'Spring Launch 2026', - value: {type: 'custom', anchor: '@', valueType: 'campaign', data: {title: 'Spring Launch 2026'}} - } -]); - -let prompt3Base = new PromptFieldValue([ - {type: 'text', text: 'Summarize the '}, - { - type: 'token', - text: 'Welcome Flow', - value: {type: 'custom', anchor: '@', valueType: 'journey', data: {title: 'Welcome Flow'}} - } -]); - -let prompt4 = new PromptFieldValue( - [ - {type: 'text', text: 'Detect audiences in '}, - { - type: 'token', - text: 'Journey', - value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} - }, - {type: 'text', text: ' that changed significantly in the past '}, - {type: 'token', text: 'date', value: {type: 'placeholder', placeholderType: 'text'}} - ] - // {selectedRange: new TokenFieldValue.SelectedRange({index: 1, offset: 0}, {index: 1, offset: 1})} -); - -export const prompts = [ - prompt1.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt1))), - prompt2.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt2))), - prompt3Base.replaceRange( - atEnd(prompt3Base), - atEnd(prompt3Base), - ' journey performance from test.com ' - ), - prompt4 +export const suggestions = [ + new PromptFieldValue([{type: 'text', text: 'Summarize this conversation'}]), + new PromptFieldValue([ + {type: 'text', text: 'Suggest places to eat after the hike within '}, + {type: 'token', text: '#', value: {type: 'placeholder', placeholderType: 'text'}}, + {type: 'text', text: ' miles'} + ]) ]; diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 1a338468957..460973f18b0 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -255,7 +255,7 @@ This example combines prompt suggestions, token completions, attachments, toolba import {useState, useRef} from 'react'; import { AttachFileMenuItem, - InsertTextMenuItem, + CommandMenuItem, InsertTokenMenuItem, InsertMenuButton, PromptField, @@ -269,14 +269,11 @@ import { PromptFieldVoiceButton, PromptToken, PromptTokenField, - TokenFieldValue, - MessageSuggestion, - MessageSuggestionList + TokenFieldValue } from '@react-spectrum/ai'; import {type FocusableRefValue} from '@react-types/shared'; -import {getIcon, prompts, slashCommands, objects, renderCompletions, type UploadState} from './ai-component-helpers/promptfield'; +import {getIcon, slashCommands, objects, renderCompletions, suggestions, type UploadState} from './ai-component-helpers/promptfield'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import * as data from '@react-spectrum/ai/loader'; import {Collection, Content, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; import Data from '@react-spectrum/s2/icons/Data'; @@ -292,9 +289,6 @@ function Example(args) { let promptFieldRef = useRef>(null); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); - let historyRef = useRef([]); - let historyIndexRef = useRef(-1); - let isHistoryNavigating = useRef(false); /*- end highlight -*/ let mockUpload = async (id: string) => { @@ -316,74 +310,33 @@ function Example(args) { }); }; - let handleChange = (newValue: TokenFieldValue) => { - if (!isHistoryNavigating.current) { - // if user edits the field, then we want to reset the index so up arrow starts from latest prompt again - historyIndexRef.current = -1; - } - isHistoryNavigating.current = false; - setValue(newValue); + let clearPrompt = () => { + setValue(new PromptFieldValue([])); + setAttachments([]); + alert('Conversation cleared'); + }; + + let compactPrompt = () => { + alert('Conversation compacted'); }; return ( -
- +
+ { + setValue(value); + promptFieldRef.current?.focus(); + }}> {onSend => ( -
- {/*- begin highlight -*/} - - {prompts.slice(0, 2).map((prompt, i) => ( - { - setValue(prompt); - promptFieldRef.current?.focus(); - }}> - {prompt.segments.map((s, i) => - s.type === 'token' ? ( - - {getIcon(s) && {getIcon(s)}} - {s.text} - - ) : ( - s.text - ) - )} - - ))} - - {/*- end highlight -*/} { - historyRef.current = [...historyRef.current, prompt]; - historyIndexRef.current = -1; onSend(prompt); setValue(new PromptFieldValue([])); setAttachments([]); @@ -436,13 +389,7 @@ function Example(args) { { - return renderCompletions(filterValue, { - valueType, - onClear: () => { - setValue(new PromptFieldValue([])); - setAttachments([]); - } - }); + return renderCompletions(filterValue, {valueType, onClear: clearPrompt, onCompact: compactPrompt}); }} pixelLoader={data[args.pixelLoader]} shouldAnimatePixelLoader @@ -467,40 +414,15 @@ function Example(args) { Commands item.kind === 'command')}> - {item => - item.command === '/clear' ? ( - { - setValue(new PromptFieldValue([])); - setAttachments([]); - }}> - {item.command} - {item.description} - - ) : item.command === '/compact' ? ( - console.log('onCompact')}> - {item.command} - {item.description} - - ) : item.command === '/feedback' || item.command === '/btw' ? ( - - {item.command} - {item.description} - - ) : ( - - {item.command} - {item.description} - - ) - } + {item => ( + + + {item.command} + {item.description} + + )} @@ -517,6 +439,7 @@ function Example(args) { text: item.command, value: {type: 'custom', anchor: '/', valueType: item.kind, data: item} }}> + {item.command} {item.description} @@ -553,15 +476,13 @@ function Example(args) {
- {/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */}
- console.log('onToggle')} /> +
{/*- end highlight -*/} -
)}
From 5171770b414d444da7d3c5610792a20c9785ed18 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 31 Aug 2026 16:52:24 -0700 Subject: [PATCH 13/27] add illustration for AI components --- .../dark/AIComponents.avif | Bin 0 -> 7349 bytes .../dark/WorkingWithAI.avif | Bin 6495 -> 0 bytes .../light/AIComponents.avif | Bin 0 -> 6277 bytes .../light/WorkingWithAI.avif | Bin 5454 -> 0 bytes packages/dev/s2-docs/src/ComponentCard.tsx | 7 ++++--- 5 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 packages/dev/s2-docs/assets/component-illustrations/dark/AIComponents.avif delete mode 100644 packages/dev/s2-docs/assets/component-illustrations/dark/WorkingWithAI.avif create mode 100644 packages/dev/s2-docs/assets/component-illustrations/light/AIComponents.avif delete mode 100644 packages/dev/s2-docs/assets/component-illustrations/light/WorkingWithAI.avif diff --git a/packages/dev/s2-docs/assets/component-illustrations/dark/AIComponents.avif b/packages/dev/s2-docs/assets/component-illustrations/dark/AIComponents.avif new file mode 100644 index 0000000000000000000000000000000000000000..6a3e6158f0ea1d7eab36bd9195db91c61e883a0f GIT binary patch literal 7349 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 IBYeC64_-I5B>(^b literal 0 HcmV?d00001 diff --git a/packages/dev/s2-docs/assets/component-illustrations/dark/WorkingWithAI.avif b/packages/dev/s2-docs/assets/component-illustrations/dark/WorkingWithAI.avif deleted file mode 100644 index c4aaac621058970cb666826c7d6d455b2474c7b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6495 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 lu9^Bmt`jPj}J73Q^ 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%0D)l z7vAW{NoepbW!?FczM^{PZ{)(cFb_|;$>_nlhZSoz6tkz^=1DP8pO$N;7nJqS<~{G! zyT$#mMgyn9zRJ2T@bP}oMz-uQ+%Dew8c>(&$`sp`NxepBK&Zsuu752IClr0$fv~x1 zZ!pJ@Zi2hxmz5t?_&c3|?phG;Q$y!RE{G_XE@|~ubgH`kszAv?;FEjWJjKPh&*;Vv z#Bb_2(msJk^c>YQqk4V@*23S_`|tNszi}L0MVBtGy|=-#fWuCogzxod3jo( zs=6iy!y~)SpM)^)82NsZRP|$SROs_zky(!^*#6j(5XLxClkEG$)DAi4cNcEl2BI9D zRlp-_S$IM58fH956$g?ofzeFiAEe$A)UEnn!HnID7{w;voJ=PMV@FXsLt0TSw;mx# z|C~LMYDP`e$q*qp{`cqy0X7}$aAQ^LVNkL^Mf@2OwwytM%QCb&hm4D5l2;n?@Mu_n zW>~3qBB&*>$VH7GO^#X#?XSY005@UuW(|BbFuKBz-1lUHx1Ex?@$ckcWHlo{W14ce z>YpZ04V21Zm4RCQV3QESJlb?6zKpbU7=4tqNP6bY&Qa0rZm6G@hL9gR!3R|03@&lS z#kvibEccx%Xj#}+3MjlcjVa0{9naGFk<~wl+p5}4cS!}x?p0YdNj1cJmh42y*C&03 zQl(&RAkz3)PGlaypa}#D(tT#nEy3#=Ob^E&kXUoJzVERyeXACe?Z&tbH{8V0rnN|| zSHIo<6~&oFRAQ+(&)`u$iGhf7%f3>Aj{N<0dU(dOp)+epo{fV9lCdo(0*OvUV^O2+ zg6Gr%RXDVwrn}fD=xN{2#4u)l68=ohWQ!S$c9#rBvH(TH&azysB!56>F)6R3FCoe} zTMxvy9BS%95JiPCo$lXP0WOYsC7$@4`40)jwGv!?mQW=$WoIWVVUpjTmDc3F;bl`$ z`t6t!Q%SqbJ=ySnaogO*CNNQh4WDT%kQ)#|CVYyddwY+LTGg;aOkGqz6HXJV7?g!1 zu)5MICV5^hnF*F%8UJ|8`NdH$;dq-_qee_O_|CE}563_1so-f`fe!n>rL7;R1sA*WSI_^`t-d2{xQ z#vgJiS=C+31&OSI4bmneH-l~N^h@vv9R^~|Su{?7i&0*=x0~v%BB?4M_Avpj4S~Q6 zpd{wY(JtefGNJa}i8$jjU$ADW39KX6EVV5QrhrEwMZhZQOW-Y^Q;Z7GaP00Aon~i` z_jbjO)}l}MvzV{|ss%HC*pN2sNv3Db_i~G$x>FMEs_V1M@vh4z1dgsf-TAGb!(dQ! zkT92qnf=!-c55B=f^K__^c-#R2z^Lr9$BJ)k3||5IwZ$?}Iqkltm>TMQl}5xs`3jdI1V|>gaXixzs@dp$*ikQKY<@ci0l`=UWuRX;-F0 zVve4bYHYK8dKOp3uYZL?jK=%Lc$Z6E>8RSH;$yE(r^vm^1kK_ca*2HKMJMgbPV)V ziK(PQpD{zzKPn@~=mjz5s0Ke zAPG0kbtfgRX7|wR)l`UPro}Or6K}2SBWH6LcVa89)SzP-cn56m8fNqwhNynApX_1e zkxyYNYx}hT)HB%2>_+@L_fhvoXPJBtfl53T`CJb0XaQ(L5wW}cMQR5WKk*KVyME-l X`HbYr=k1h#)qm6m8}~^rV6p0dsxPK? literal 0 HcmV?d00001 diff --git a/packages/dev/s2-docs/assets/component-illustrations/light/WorkingWithAI.avif b/packages/dev/s2-docs/assets/component-illustrations/light/WorkingWithAI.avif deleted file mode 100644 index 2b70a63514f6aedaad41e52e6f8e931da7cd30e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5454 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 diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx index 975f89cffa5..ef2ae53865f 100644 --- a/packages/dev/s2-docs/src/ComponentCard.tsx +++ b/packages/dev/s2-docs/src/ComponentCard.tsx @@ -199,8 +199,8 @@ import TreeDark from 'url:../assets/component-illustrations/dark/Tree.avif'; import TreeLight from 'url:../assets/component-illustrations/light/Tree.avif'; import UtilityDark from 'url:../assets/component-illustrations/dark/Utility.avif'; import UtilityLight from 'url:../assets/component-illustrations/light/Utility.avif'; -import WorkingWithAIDark from 'url:../assets/component-illustrations/dark/WorkingWithAI.avif'; -import WorkingWithAILight from 'url:../assets/component-illustrations/light/WorkingWithAI.avif'; +import AIComponentsDark from 'url:../assets/component-illustrations/dark/AIComponents.avif'; +import AIComponentsLight from 'url:../assets/component-illustrations/light/AIComponents.avif'; export interface ComponentCardItem { id: string; @@ -307,6 +307,7 @@ const componentIllustrations: Record = { Virtualizer: [CollectionLight, CollectionDark], VisuallyHidden: [AccessibilityLight, AccessibilityDark], // Guides + 'AI Components': [AIComponentsLight, AIComponentsDark], Collections: [CollectionLight, CollectionDark], Customization: [StyleLight, StyleDark], 'Drag and Drop': [DragAndDropLight, DragAndDropDark], @@ -315,7 +316,7 @@ const componentIllustrations: Record = { 'Getting started': [GettingStartedLight, GettingStartedDark], 'MCP Server': [McpServerLight, McpServerDark], Quality: [AccessibilityLight, AccessibilityDark], - 'Working with AI': [WorkingWithAILight, WorkingWithAIDark], + 'Working with AI': [AIComponentsLight, AIComponentsDark], Selection: [SelectionLight, SelectionDark], 'Style Macro': [StyleMacroLight, StyleMacroDark], Styling: [StyleLight, StyleDark], From 460ebb19264a3c4ffa40ea442e658e4207df9674 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 31 Aug 2026 17:29:06 -0700 Subject: [PATCH 14/27] add missing api sections, update chat example with prose, add token to suggestion --- .../pages/s2/ai-component-helpers/chat.tsx | 9 +- .../dev/s2-docs/pages/s2/ai-components.mdx | 165 ++++++++++++++++-- packages/dev/s2-docs/src/ComponentCard.tsx | 4 +- 3 files changed, 161 insertions(+), 17 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index 8f73aed49fa..e2c79ec21af 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -48,7 +48,9 @@ function SuggestionLabel({value}: {value: TokenFieldValue}) { <> {value.segments.map((seg, i) => seg.type === 'token' ? ( - + {getIcon(seg) && {getIcon(seg)}} {seg.text} @@ -69,8 +71,7 @@ let initialResponses = [ { id: 1, type: 'system', - content: - 'Sure! How many days do you have, and do you prefer hiking, skiing, or just relaxing?' + content: 'Sure! How many days do you have, and do you prefer hiking, skiing, or just relaxing?' }, { id: 2, @@ -293,7 +294,7 @@ export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { [] ); - let items = useMemo(() => { + let items = useMemo(() => { if (isGenerating || !suggestions) { return messages; } diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 460973f18b0..9aaf4b28490 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -36,11 +36,12 @@ import { PromptTokenField } from '@react-spectrum/ai'; -function BasicPrompt() { +function BasicPrompt(props) { let [value, setValue] = useState(() => new PromptFieldValue([])); return ( { @@ -76,20 +77,30 @@ import { PromptTokenField } from '@react-spectrum/ai'; -let suggestions = ['Summarize this report', 'Draft a project brief', 'Find risks in this plan']; let people = ['Customers', 'Designers', 'Developers']; +let suggestions = [ + new PromptFieldValue([{type: 'text', text: 'Summarize this report'}]), + new PromptFieldValue([ + {type: 'text', text: 'Draft a project brief for '}, + { + type: 'token', + text: 'Designers', + value: {type: 'custom', anchor: '@', valueType: 'person', data: 'Designers'} + } + ]), + new PromptFieldValue([{type: 'text', text: 'Find risks in this plan'}]) +]; + function PromptSuggestions() { let [value, setValue] = useState(() => new PromptFieldValue([])); return (
- {suggestions.map(suggestion => ( - setValue(new PromptFieldValue([{type: 'text', text: suggestion}]))}> - {suggestion} + {suggestions.map((suggestion, i) => ( + setValue(suggestion)}> + {suggestion.toString()} ))} @@ -188,13 +199,37 @@ Compose a conversation from `Chat`, `Thread`, and message components. The thread ```tsx render type="s2" "use client"; import {Chat, Thread, ThreadItem, UserMessage} from '@react-spectrum/ai'; +import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; let messages = [ {id: 1, type: 'user', text: 'Summarize the campaign results.'}, - {id: 2, type: 'assistant', text: 'Engagement increased 18% this month, led by email and social.'}, + { + id: 2, + type: 'assistant', + text: 'Engagement increased 18% this month, led by email and social. See the full report for a channel breakdown.', + content: ( + <> + Engagement increased 18% this month, led by email and social. See the{' '} + full report for a channel breakdown. + + ) + }, {id: 3, type: 'user', text: 'Which channel performed best?'}, - {id: 4, type: 'assistant', text: 'Email drove the most conversions, with social close behind.'} + { + id: 4, + type: 'assistant', + text: 'Email drove the most conversions, with social close behind: Email with 4,200 conversions, Social with 3,100 conversions.', + content: ( + <> +

Email drove the most conversions, with social close behind:

+
    +
  • Email: 4,200 conversions
  • +
  • Social: 3,100 conversions
  • +
+ + ) + } ]; function BasicChat() { @@ -208,6 +243,7 @@ function BasicChat() { overflow: 'hidden', boxSizing: 'border-box', paddingX: 4, + backgroundColor: 'layer-2', minWidth: 0 })}>
{message.text} ) : ( - {message.text} + +
{message.content}
+
) } @@ -492,6 +530,59 @@ function Example(args) { ## API +```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', ThreadScrollButton: '#threadscrollbutton', UserMessage: '#usermessage', MessageFeedback: '#messagefeedback', MessageSource: '#messagesource', SourceList: '#sourcelist', SourceListItem: '#sourcelistitem', ResponseStatus: '#responsestatus', ResponseStatusTitle: '#responsestatustitle', ResponseStatusPanel: '#responsestatuspanel', ExecutionTrace: '#executiontrace', ExecutionTraceItem: '#executiontraceitem', MessageSuggestionList: '#messagesuggestionlist', MessageSuggestion: '#messagesuggestion', Attachment: '#attachment', AttachmentList: '#attachmentlist', AttachmentPreview: '#attachmentpreview', PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar', PromptFieldSubmitButton: '#promptfieldsubmitbutton', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptToken: '#prompttoken', PromptFieldAttachmentList: '#promptfieldattachmentlist', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem'}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {attachment => ( + + + + )} + + + {token => } + + + + + + + + + + + + + +``` + ### Prompt field ```tsx links={{PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar', PromptFieldSubmitButton: '#promptfieldsubmitbutton', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptToken: '#prompttoken', PromptFieldAttachmentList: '#promptfieldattachmentlist', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem'}} @@ -603,12 +694,28 @@ function Example(args) { ### Chat -```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', ThreadScrollButton: '#threadscrollbutton', UserMessage: '#usermessage'}} +```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', ThreadScrollButton: '#threadscrollbutton', UserMessage: '#usermessage', MessageFeedback: '#messagefeedback', MessageSource: '#messagesource', SourceList: '#sourcelist', SourceListItem: '#sourcelistitem', ResponseStatus: '#responsestatus', ResponseStatusTitle: '#responsestatustitle', ResponseStatusPanel: '#responsestatuspanel', ExecutionTrace: '#executiontrace', ExecutionTraceItem: '#executiontraceitem'}} or assistant content + + + + + + + + + + + + + + + + @@ -638,3 +745,39 @@ function Example(args) { #### UserMessage + +#### MessageFeedback + + + +#### MessageSource + + + +#### SourceList + + + +#### SourceListItem + + + +#### ResponseStatus + + + +#### ResponseStatusTitle + + + +#### ResponseStatusPanel + + + +#### ExecutionTrace + + + +#### ExecutionTraceItem + + diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx index ef2ae53865f..6d780a5ed6b 100644 --- a/packages/dev/s2-docs/src/ComponentCard.tsx +++ b/packages/dev/s2-docs/src/ComponentCard.tsx @@ -12,6 +12,8 @@ import ActionMenuDark from 'url:../assets/component-illustrations/dark/ActionMen import ActionMenuLight from 'url:../assets/component-illustrations/light/ActionMenu.avif'; import AdobeDark from 'url:../assets/component-illustrations/dark/Adobe.avif'; import AdobeLight from 'url:../assets/component-illustrations/light/Adobe.avif'; +import AIComponentsDark from 'url:../assets/component-illustrations/dark/AIComponents.avif'; +import AIComponentsLight from 'url:../assets/component-illustrations/light/AIComponents.avif'; import AutocompleteDark from 'url:../assets/component-illustrations/dark/Autocomplete.avif'; import AutocompleteLight from 'url:../assets/component-illustrations/light/Autocomplete.avif'; import AvatarDark from 'url:../assets/component-illustrations/dark/Avatar.avif'; @@ -199,8 +201,6 @@ import TreeDark from 'url:../assets/component-illustrations/dark/Tree.avif'; import TreeLight from 'url:../assets/component-illustrations/light/Tree.avif'; import UtilityDark from 'url:../assets/component-illustrations/dark/Utility.avif'; import UtilityLight from 'url:../assets/component-illustrations/light/Utility.avif'; -import AIComponentsDark from 'url:../assets/component-illustrations/dark/AIComponents.avif'; -import AIComponentsLight from 'url:../assets/component-illustrations/light/AIComponents.avif'; export interface ComponentCardItem { id: string; From 3d5af869e978b910345447ca2b873faf3360864b Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 31 Aug 2026 17:39:11 -0700 Subject: [PATCH 15/27] move complete example to top, fix token styling for suggestions example --- .../dev/s2-docs/pages/s2/ai-components.mdx | 508 +++++++++--------- 1 file changed, 263 insertions(+), 245 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 9aaf4b28490..114016b5c86 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -15,6 +15,246 @@ export const description = 'Build AI-powered experiences with prompts, messages, React Spectrum provides components for building AI-powered experiences, including prompts, messages, suggestions, attachments, and voice input. +```tsx render type="s2" files={["packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx", "packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx"]} +"use client"; +import {useState, useRef} from 'react'; +import { + AttachFileMenuItem, + CommandMenuItem, + InsertTokenMenuItem, + InsertMenuButton, + PromptField, + Attachment, + AttachmentPreview, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptFieldVoiceButton, + PromptToken, + PromptTokenField, + TokenFieldValue +} from '@react-spectrum/ai'; +import {type FocusableRefValue} from '@react-types/shared'; +import {getIcon, slashCommands, objects, renderCompletions, suggestions, type UploadState} from './ai-component-helpers/promptfield'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import * as data from '@react-spectrum/ai/loader'; +import {Collection, Content, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; +import Data from '@react-spectrum/s2/icons/Data'; +import Plugin from '@react-spectrum/s2/icons/Plugin'; +import Prompt from '@react-spectrum/s2/icons/Prompt'; +import {VirtualizedStreamingChat} from './ai-component-helpers/chat'; + + +function Example(args) { + /*- begin highlight -*/ + let {placeholder, menuWidth, ...otherArgs} = args; + let [value, setValue] = useState(() => new PromptFieldValue([])); + let promptFieldRef = useRef>(null); + let [attachments, setAttachments] = useState([]); + let [attachmentState, setAttachmentState] = useState>(new Map()); + /*- end highlight -*/ + + let mockUpload = async (id: string) => { + await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); + setAttachmentState(prev => { + let item = prev.get(id); + if (!item || item.status === 'completed') { + return prev; + } + let newState = new Map(prev); + let progress = (item.progress ?? 0) + 1; + if (progress >= 100) { + newState.set(id, {status: 'completed'}); + } else { + newState.set(id, {status: 'uploading', progress}); + mockUpload(id); + } + return newState; + }); + }; + + let clearPrompt = () => { + setValue(new PromptFieldValue([])); + setAttachments([]); + alert('Conversation cleared'); + }; + + let compactPrompt = () => { + alert('Conversation compacted'); + }; + + return ( +
+ { + setValue(value); + promptFieldRef.current?.focus(); + }}> + {onSend => ( + { + onSend(prompt); + setValue(new PromptFieldValue([])); + setAttachments([]); + setAttachmentState(new Map()); + }} + acceptedAttachmentTypes={['*/*']} + onAddAttachments={newAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + newAttachments.forEach(attachment => { + newState.set(attachment.id, {status: 'uploading', progress: 0}); + mockUpload(attachment.id); + }); + return newState; + }); + }} + onRemoveAttachments={removedAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + removedAttachments.forEach(attachment => { + newState.delete(attachment.id); + }); + return newState; + }); + }}> + {/*- begin highlight -*/} + + {attachment => { + let state = attachmentState.get(attachment.id); + return ( + + + {args.attachmentVariant === 'card' && ( + + {attachment.file.name} + + {attachment.file.type.split('/').pop()?.toUpperCase()} + + + )} + + ); + }} + + {/*- end highlight -*/} + {/*- begin highlight -*/} + { + return renderCompletions(filterValue, {valueType, onClear: clearPrompt, onCompact: compactPrompt}); + }} + pixelLoader={data[args.pixelLoader]} + shouldAnimatePixelLoader + placeholder={placeholder} + menuWidth={menuWidth}> + {token => ( + + {getIcon(token)} + {token.text} + + )} + + {/*- end highlight -*/} + {/*- begin highlight -*/} + +
+ + + + + + Commands + + item.kind === 'command')}> + {item => ( + + + {item.command} + {item.description} + + )} + + + + + + Skills + + item.kind === 'skill')}> + {item => ( + + + {item.command} + {item.description} + + )} + + + + + + Reference an object + + + {item => ( + +
+ {item.section} +
+ + {item => ( + + {item.title} + + )} + +
+ )} +
+
+
+
+
+ + +
+
+ {/*- end highlight -*/} +
+ )} +
+
+ ); +} +``` + ## Installation AI components are published as a separate package from `@react-spectrum/s2`. @@ -76,6 +316,22 @@ import { PromptFieldValue, PromptTokenField } from '@react-spectrum/ai'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +/*- begin collapse -*/ +let suggestionToken = style({ + outlineStyle: 'solid', + outlineWidth: 1, + outlineColor: 'transparent-overlay-1000/20', + outlineOffset: -1, + borderRadius: 'pill', + paddingX: 8, + paddingY: 0, + fontSize: 'ui', + display: 'inline-flex', + verticalAlign: 'baseline' +}); +/*- end collapse -*/ let people = ['Customers', 'Designers', 'Developers']; @@ -100,7 +356,13 @@ function PromptSuggestions() { {suggestions.map((suggestion, i) => ( setValue(suggestion)}> - {suggestion.toString()} + {suggestion.segments.map((segment, j) => + segment.type === 'token' ? ( + {segment.text} + ) : ( + segment.text + ) + )} ))} @@ -284,250 +546,6 @@ function BasicChat() { } ``` -## Complete example - -This example combines prompt suggestions, token completions, attachments, toolbar actions, and a streaming chat thread. - -```tsx render type="s2" files={["packages/dev/s2-docs/pages/s2/ai-component-helpers/promptfield.tsx", "packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx"]} -"use client"; -import {useState, useRef} from 'react'; -import { - AttachFileMenuItem, - CommandMenuItem, - InsertTokenMenuItem, - InsertMenuButton, - PromptField, - Attachment, - AttachmentPreview, - PromptFieldAttachment, - PromptFieldAttachmentList, - PromptFieldSubmitButton, - PromptFieldToolbar, - PromptFieldValue, - PromptFieldVoiceButton, - PromptToken, - PromptTokenField, - TokenFieldValue -} from '@react-spectrum/ai'; -import {type FocusableRefValue} from '@react-types/shared'; -import {getIcon, slashCommands, objects, renderCompletions, suggestions, type UploadState} from './ai-component-helpers/promptfield'; -import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import * as data from '@react-spectrum/ai/loader'; -import {Collection, Content, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; -import Data from '@react-spectrum/s2/icons/Data'; -import Plugin from '@react-spectrum/s2/icons/Plugin'; -import Prompt from '@react-spectrum/s2/icons/Prompt'; -import {VirtualizedStreamingChat} from './ai-component-helpers/chat'; - - -function Example(args) { - /*- begin highlight -*/ - let {placeholder, menuWidth, ...otherArgs} = args; - let [value, setValue] = useState(() => new PromptFieldValue([])); - let promptFieldRef = useRef>(null); - let [attachments, setAttachments] = useState([]); - let [attachmentState, setAttachmentState] = useState>(new Map()); - /*- end highlight -*/ - - let mockUpload = async (id: string) => { - await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); - setAttachmentState(prev => { - let item = prev.get(id); - if (!item || item.status === 'completed') { - return prev; - } - let newState = new Map(prev); - let progress = (item.progress ?? 0) + 1; - if (progress >= 100) { - newState.set(id, {status: 'completed'}); - } else { - newState.set(id, {status: 'uploading', progress}); - mockUpload(id); - } - return newState; - }); - }; - - let clearPrompt = () => { - setValue(new PromptFieldValue([])); - setAttachments([]); - alert('Conversation cleared'); - }; - - let compactPrompt = () => { - alert('Conversation compacted'); - }; - - return ( -
- { - setValue(value); - promptFieldRef.current?.focus(); - }}> - {onSend => ( - { - onSend(prompt); - setValue(new PromptFieldValue([])); - setAttachments([]); - setAttachmentState(new Map()); - }} - acceptedAttachmentTypes={['*/*']} - onAddAttachments={newAttachments => { - setAttachmentState(prev => { - let newState = new Map(prev); - newAttachments.forEach(attachment => { - newState.set(attachment.id, {status: 'uploading', progress: 0}); - mockUpload(attachment.id); - }); - return newState; - }); - }} - onRemoveAttachments={removedAttachments => { - setAttachmentState(prev => { - let newState = new Map(prev); - removedAttachments.forEach(attachment => { - newState.delete(attachment.id); - }); - return newState; - }); - }}> - {/*- begin highlight -*/} - - {attachment => { - let state = attachmentState.get(attachment.id); - return ( - - - {args.attachmentVariant === 'card' && ( - - {attachment.file.name} - - {attachment.file.type.split('/').pop()?.toUpperCase()} - - - )} - - ); - }} - - {/*- end highlight -*/} - {/*- begin highlight -*/} - { - return renderCompletions(filterValue, {valueType, onClear: clearPrompt, onCompact: compactPrompt}); - }} - pixelLoader={data[args.pixelLoader]} - shouldAnimatePixelLoader - placeholder={placeholder} - menuWidth={menuWidth}> - {token => ( - - {getIcon(token)} - {token.text} - - )} - - {/*- end highlight -*/} - {/*- begin highlight -*/} - -
- - - - - - Commands - - item.kind === 'command')}> - {item => ( - - - {item.command} - {item.description} - - )} - - - - - - Skills - - item.kind === 'skill')}> - {item => ( - - - {item.command} - {item.description} - - )} - - - - - - Reference an object - - - {item => ( - -
- {item.section} -
- - {item => ( - - {item.title} - - )} - -
- )} -
-
-
-
-
- - -
-
- {/*- end highlight -*/} -
- )} -
-
- ); -} -``` - ## API ```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', ThreadScrollButton: '#threadscrollbutton', UserMessage: '#usermessage', MessageFeedback: '#messagefeedback', MessageSource: '#messagesource', SourceList: '#sourcelist', SourceListItem: '#sourcelistitem', ResponseStatus: '#responsestatus', ResponseStatusTitle: '#responsestatustitle', ResponseStatusPanel: '#responsestatuspanel', ExecutionTrace: '#executiontrace', ExecutionTraceItem: '#executiontraceitem', MessageSuggestionList: '#messagesuggestionlist', MessageSuggestion: '#messagesuggestion', Attachment: '#attachment', AttachmentList: '#attachmentlist', AttachmentPreview: '#attachmentpreview', PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar', PromptFieldSubmitButton: '#promptfieldsubmitbutton', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptToken: '#prompttoken', PromptFieldAttachmentList: '#promptfieldattachmentlist', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem'}} From f8fe7f36504873549b492dac0c9ca9cf05fbbada Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 1 Sep 2026 11:44:16 +1000 Subject: [PATCH 16/27] fix props --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 114016b5c86..322d516ed07 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -265,7 +265,7 @@ AI components are published as a separate package from `@react-spectrum/s2`. Use `PromptField` as the foundation for allowing the user to submit a prompt. It manages an editable sequence of text and tokens, while `PromptTokenField` renders the input and `PromptFieldToolbar` contains its actions. -```tsx render type="s2" +```tsx render type="s2" docs={docs.exports.PromptField} props={['variant', 'size']} "use client"; import {useState} from 'react'; import { @@ -282,6 +282,7 @@ function BasicPrompt(props) { return ( { From 5d5def887e3f278309c8bdcbda5a17d275b1b66f Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 31 Aug 2026 23:12:12 -0400 Subject: [PATCH 17/27] minor example updates --- .../pages/s2/ai-component-helpers/chat.tsx | 154 ++++----- .../dev/s2-docs/pages/s2/ai-components.mdx | 311 ++++++++---------- 2 files changed, 219 insertions(+), 246 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index e2c79ec21af..67ad2bc8129 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -18,50 +18,11 @@ import { TokenFieldValue, UserMessage } from '@react-spectrum/ai'; +import * as loaders from '@react-spectrum/ai/loader'; import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; import {ReactNode, useEffect, useMemo, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -let suggestionToken = style({ - outlineStyle: { - default: 'solid', - isPlaceholder: 'dashed' - }, - outlineWidth: 1, - outlineColor: { - default: 'transparent-overlay-1000/20', - isPlaceholder: 'transparent-overlay-1000/40' - }, - outlineOffset: -1, - borderRadius: 'pill', - paddingX: 8, - paddingY: 0, - fontSize: 'ui', - display: 'inline-flex', - alignItems: 'baseline', - gap: 4, - verticalAlign: 'baseline' -}); - -function SuggestionLabel({value}: {value: TokenFieldValue}) { - return ( - <> - {value.segments.map((seg, i) => - seg.type === 'token' ? ( - - {getIcon(seg) && {getIcon(seg)}} - {seg.text} - - ) : ( - seg.text - ) - )} - - ); -} - let initialResponses = [ { id: 0, @@ -104,40 +65,6 @@ type StreamingMessage = } | {id: number | string; type: 'suggestions'; suggestions: TokenFieldValue[]}; -function StatusThreadItem({msg}: {msg: Extract}) { - let isStreaming = msg.status === 'pending'; - let lastStep = msg.steps[msg.steps.length - 1]; - let title = isStreaming - ? `${lastStep.label}…` - : msg.steps.length > 1 - ? `Completed ${msg.steps.length} steps` - : lastStep.label; - let announcement = isStreaming ? `${lastStep.label}…` : `${title} complete`; - return ( - - - {title} - - - {msg.steps.map(step => ( - {step.detail}

- ) - }> - {step.label} -
- ))} -
-
-
-
- ); -} - export interface VirtualizedStreamingChatProps { children: (onSend: (prompt: TokenFieldValue) => void) => ReactNode; /** Suggestions shown at the end of the thread. Hidden while a response is streaming in. */ @@ -352,8 +279,7 @@ export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { flexGrow: 1, overflowX: 'hidden', overflowY: 'auto', - scrollPadding: 8, - rowGap: 16 + scrollPadding: 8 })}> {(msg: StreamingMessage) => { if (msg.type === 'user') { @@ -371,7 +297,7 @@ export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { if (msg.type === 'suggestions') { return ( - + {msg.suggestions.map((s, i) => ( onSelectSuggestion?.(s)}> @@ -397,3 +323,77 @@ export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) {
); } + +function StatusThreadItem({msg}: {msg: Extract}) { + let isStreaming = msg.status === 'pending'; + let lastStep = msg.steps[msg.steps.length - 1]; + let title = isStreaming + ? `${lastStep.label}…` + : msg.steps.length > 1 + ? `Completed ${msg.steps.length} steps` + : lastStep.label; + let announcement = isStreaming ? `${lastStep.label}…` : `${title} complete`; + return ( + + + {title} + + + {msg.steps.map(step => ( + {step.detail}

+ ) + }> + {step.label} +
+ ))} +
+
+
+
+ ); +} + +let suggestionToken = style({ + outlineStyle: { + default: 'solid', + isPlaceholder: 'dashed' + }, + outlineWidth: 1, + outlineColor: { + default: 'transparent-overlay-1000/20', + isPlaceholder: 'transparent-overlay-1000/40' + }, + outlineOffset: -1, + borderRadius: 'pill', + paddingX: 8, + paddingY: 0, + fontSize: 'ui', + display: 'inline-flex', + alignItems: 'baseline', + gap: 4, + verticalAlign: 'baseline' +}); + +function SuggestionLabel({value}: {value: TokenFieldValue}) { + return ( + <> + {value.segments.map((seg, i) => + seg.type === 'token' ? ( + + {getIcon(seg) && {getIcon(seg)}} + {seg.text} + + ) : ( + seg.text + ) + )} + + ); +} diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 322d516ed07..1cae3fb306a 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -39,22 +39,17 @@ import { import {type FocusableRefValue} from '@react-types/shared'; import {getIcon, slashCommands, objects, renderCompletions, suggestions, type UploadState} from './ai-component-helpers/promptfield'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import * as data from '@react-spectrum/ai/loader'; import {Collection, Content, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; import Data from '@react-spectrum/s2/icons/Data'; import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import {VirtualizedStreamingChat} from './ai-component-helpers/chat'; - -function Example(args) { - /*- begin highlight -*/ - let {placeholder, menuWidth, ...otherArgs} = args; +function Example() { let [value, setValue] = useState(() => new PromptFieldValue([])); let promptFieldRef = useRef>(null); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); - /*- end highlight -*/ let mockUpload = async (id: string) => { await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); @@ -86,170 +81,148 @@ function Example(args) { }; return ( -
- { - setValue(value); - promptFieldRef.current?.focus(); - }}> - {onSend => ( - { - onSend(prompt); - setValue(new PromptFieldValue([])); - setAttachments([]); - setAttachmentState(new Map()); - }} - acceptedAttachmentTypes={['*/*']} - onAddAttachments={newAttachments => { - setAttachmentState(prev => { - let newState = new Map(prev); - newAttachments.forEach(attachment => { - newState.set(attachment.id, {status: 'uploading', progress: 0}); - mockUpload(attachment.id); - }); - return newState; - }); - }} - onRemoveAttachments={removedAttachments => { - setAttachmentState(prev => { - let newState = new Map(prev); - removedAttachments.forEach(attachment => { - newState.delete(attachment.id); - }); - return newState; - }); +
+ { + setValue(value); + promptFieldRef.current?.focus(); }}> - {/*- begin highlight -*/} - - {attachment => { - let state = attachmentState.get(attachment.id); - return ( - - - {args.attachmentVariant === 'card' && ( - - {attachment.file.name} - - {attachment.file.type.split('/').pop()?.toUpperCase()} - - - )} - - ); - }} - - {/*- end highlight -*/} - {/*- begin highlight -*/} - { - return renderCompletions(filterValue, {valueType, onClear: clearPrompt, onCompact: compactPrompt}); - }} - pixelLoader={data[args.pixelLoader]} - shouldAnimatePixelLoader - placeholder={placeholder} - menuWidth={menuWidth}> - {token => ( - - {getIcon(token)} - {token.text} - - )} - - {/*- end highlight -*/} - {/*- begin highlight -*/} - -
- - - - - - Commands - - item.kind === 'command')}> - {item => ( - + {onSend => ( + { + onSend(prompt); + setValue(new PromptFieldValue([])); + setAttachments([]); + setAttachmentState(new Map()); + }} + acceptedAttachmentTypes={['*/*']} + onAddAttachments={newAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + newAttachments.forEach(attachment => { + newState.set(attachment.id, {status: 'uploading', progress: 0}); + mockUpload(attachment.id); + }); + return newState; + }); + }} + onRemoveAttachments={removedAttachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + removedAttachments.forEach(attachment => { + newState.delete(attachment.id); + }); + return newState; + }); + }}> + + {attachment => { + let state = attachmentState.get(attachment.id); + return ( + + + + ); + }} + + { + return renderCompletions(filterValue, {valueType, onClear: clearPrompt, onCompact: compactPrompt}); + }}> + {token => ( + + {getIcon(token)} + {token.text} + + )} + + +
+ + + + - {item.command} - {item.description} - - )} -
-
- - - - Skills - - item.kind === 'skill')}> - {item => ( - + Commands + + item.kind === 'command')}> + {item => ( + + + {item.command} + {item.description} + + )} + + + + - {item.command} - {item.description} - - )} - - - - - - Reference an object - - - {item => ( - -
- {item.section} -
- - {item => ( - - {item.title} - - )} - -
- )} -
-
-
-
-
- - -
-
- {/*- end highlight -*/} - - )} -
+ Skills + + item.kind === 'skill')}> + {item => ( + + + {item.command} + {item.description} + + )} + + + + + + Reference an object + + + {item => ( + +
+ {item.section} +
+ + {item => ( + + {item.title} + + )} + +
+ )} +
+
+ +
+
+ + +
+ +
+ )} +
); } @@ -300,7 +273,7 @@ function BasicPrompt(props) { } ``` -## Suggestions and tokens +### Suggestions and tokens Suggestions can prefill a prompt, and a token field can offer context-aware completions such as mentions or commands. @@ -398,7 +371,7 @@ function PromptSuggestions() { } ``` -## Attachments and actions +### Attachments and actions Add attachments and place common actions in the toolbar. Use a custom attachment render function when an attachment needs a thumbnail or additional metadata. From 52d643000d126d59d4bacb201c8b941a21d69d5a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 31 Aug 2026 23:14:49 -0400 Subject: [PATCH 18/27] use transparent color so it works over any background --- packages/@react-spectrum/ai/src/ResponseStatus.tsx | 2 +- packages/@react-spectrum/ai/src/UserMessage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index bbcae6bd957..bf9eeb7c8ee 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -589,7 +589,7 @@ const executionTraceDetailStyle = style({ // focus ring needs to be here instead of child detail div since the fade fades the focus ring ...focusRing(), outlineOffset: -2, - backgroundColor: 'layer-1', + backgroundColor: 'transparent-overlay-50', borderRadius: 'lg', font: 'body-2xs', color: 'gray-600' diff --git a/packages/@react-spectrum/ai/src/UserMessage.tsx b/packages/@react-spectrum/ai/src/UserMessage.tsx index 29b5d91677b..533df340ea8 100644 --- a/packages/@react-spectrum/ai/src/UserMessage.tsx +++ b/packages/@react-spectrum/ai/src/UserMessage.tsx @@ -54,7 +54,7 @@ const bubble = style({ default: 16, ':has(img)': 8 }, - backgroundColor: 'gray-50', + backgroundColor: 'transparent-overlay-50', color: 'neutral', borderRadius: 'lg', font: 'body', From 46d5097b6933442275dc225a4a107ba5511890e7 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 1 Sep 2026 13:35:45 +1000 Subject: [PATCH 19/27] fix ts --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 1cae3fb306a..086bdbf0c1e 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -33,20 +33,19 @@ import { PromptFieldValue, PromptFieldVoiceButton, PromptToken, - PromptTokenField, - TokenFieldValue + PromptTokenField } from '@react-spectrum/ai'; import {type FocusableRefValue} from '@react-types/shared'; import {getIcon, slashCommands, objects, renderCompletions, suggestions, type UploadState} from './ai-component-helpers/promptfield'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Collection, Content, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; +import {Collection, SubmenuTrigger, Menu, MenuItem, MenuSection, Header, Heading, Text} from '@react-spectrum/s2'; import Data from '@react-spectrum/s2/icons/Data'; import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import {VirtualizedStreamingChat} from './ai-component-helpers/chat'; function Example() { - let [value, setValue] = useState(() => new PromptFieldValue([])); + let [value, setValue] = useState(() => new PromptFieldValue([])); let promptFieldRef = useRef>(null); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); @@ -85,7 +84,7 @@ function Example() { { - setValue(value); + setValue(value as PromptFieldValue); promptFieldRef.current?.focus(); }}> {onSend => ( From c98ddbe9c09f69f083bc59fa14028aa0738f30ce Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 31 Aug 2026 20:44:26 -0700 Subject: [PATCH 20/27] update API section for consistent API anatomy --- .../dev/s2-docs/pages/s2/ai-components.mdx | 305 ++++++++---------- 1 file changed, 141 insertions(+), 164 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 114016b5c86..4d7a9273335 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -548,129 +548,82 @@ function BasicChat() { ## API -```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', ThreadScrollButton: '#threadscrollbutton', UserMessage: '#usermessage', MessageFeedback: '#messagefeedback', MessageSource: '#messagesource', SourceList: '#sourcelist', SourceListItem: '#sourcelistitem', ResponseStatus: '#responsestatus', ResponseStatusTitle: '#responsestatustitle', ResponseStatusPanel: '#responsestatuspanel', ExecutionTrace: '#executiontrace', ExecutionTraceItem: '#executiontraceitem', MessageSuggestionList: '#messagesuggestionlist', MessageSuggestion: '#messagesuggestion', Attachment: '#attachment', AttachmentList: '#attachmentlist', AttachmentPreview: '#attachmentpreview', PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar', PromptFieldSubmitButton: '#promptfieldsubmitbutton', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptToken: '#prompttoken', PromptFieldAttachmentList: '#promptfieldattachmentlist', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem'}} +```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadScrollButton: '#threadscrollbutton', PromptField: '#promptfield'}} - - - - - - - - - - - - - - - - - - - - - - - + - - - {attachment => ( - - - - )} - - - {token => } - - - - - - - - - - - - + ``` -### Prompt field - -```tsx links={{PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar', PromptFieldSubmitButton: '#promptfieldsubmitbutton', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptToken: '#prompttoken', PromptFieldAttachmentList: '#promptfieldattachmentlist', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem'}} - - - - {token => } - - - - - - - - - - - - -``` - -#### PromptField - - +## Chat -#### PromptTokenField + - +### ThreadScrollButton -#### PromptToken + - +## Thread -#### PromptFieldAttachmentList +```tsx links={{Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', MessageSuggestionList: '#messagesuggestionlist', UserMessage: '#usermessage', ResponseStatus: '#responsestatus', MessageSource: '#messagesource', MessageFeedback: '#messagefeedback'}} + + + or or or assistant content + and/or + + + +``` - + -#### PromptFieldToolbar +### ThreadItem - + -#### PromptFieldSubmitButton +### ThreadLoadMoreItem - + -#### PromptFieldVoiceButton +### UserMessage - + -#### InsertMenuButton +### ResponseStatus + +```tsx links={{ResponseStatus: '#responsestatus', ResponseStatusTitle: '#responsestatustitle', ResponseStatusPanel: '#responsestatuspanel', ExecutionTrace: '#executiontrace', ExecutionTraceItem: '#executiontraceitem'}} + + + + + + + + +``` - + -#### AttachFileMenuItem +#### ResponseStatusTitle - + -#### InsertTextMenuItem +#### ResponseStatusPanel - + -#### InsertTokenMenuItem +#### ExecutionTrace - + -#### CommandMenuItem +#### ExecutionTraceItem - + -### Suggestions +### MessageSuggestionList ```tsx links={{MessageSuggestionList: '#messagesuggestionlist', MessageSuggestion: '#messagesuggestion'}} @@ -678,124 +631,148 @@ function BasicChat() { ``` -#### MessageSuggestionList - #### MessageSuggestion -### Attachments +### MessageFeedback -```tsx links={{Attachment: '#attachment', AttachmentList: '#attachmentlist', AttachmentPreview: '#attachmentpreview'}} - + + +### MessageSource + +```tsx links={{MessageSource: '#messagesource', SourceList: '#sourcelist', SourceListItem: '#sourcelistitem'}} + + + + + +``` + + + +#### SourceList + + + +#### SourceListItem + + + +## PromptField + +```tsx links={{PromptField: '#promptfield', PromptTokenField: '#prompttokenfield', PromptFieldToolbar: '#promptfieldtoolbar'}} + + + + + +``` + + + +### PromptFieldAttachmentList + +```tsx links={{PromptFieldAttachmentList: '#promptfieldattachmentlist', Attachment: '#attachment', AttachmentPreview: '#attachmentpreview'}} + {attachment => ( )} - + ``` + + #### Attachment -#### AttachmentList - - - #### AttachmentPreview -### Chat +### PromptTokenField -```tsx links={{Chat: '#chat', Thread: '#thread', ThreadItem: '#threaditem', ThreadLoadMoreItem: '#threadloadmoreitem', ThreadScrollButton: '#threadscrollbutton', UserMessage: '#usermessage', MessageFeedback: '#messagefeedback', MessageSource: '#messagesource', SourceList: '#sourcelist', SourceListItem: '#sourcelistitem', ResponseStatus: '#responsestatus', ResponseStatusTitle: '#responsestatustitle', ResponseStatusPanel: '#responsestatuspanel', ExecutionTrace: '#executiontrace', ExecutionTraceItem: '#executiontraceitem'}} - - - - or assistant content - - - - - - - - - - - - - - - - - - - - - +```tsx links={{PromptTokenField: '#prompttokenfield', PromptToken: '#prompttoken'}} + + {token => } + ``` -#### Chat - - - -#### Thread - - - -#### ThreadItem + - +#### PromptToken -#### ThreadLoadMoreItem + - +### PromptFieldToolbar + +```tsx links={{PromptFieldToolbar: '#promptfieldtoolbar', InsertMenuButton: '#insertmenubutton', AttachFileMenuItem: '#attachfilemenuitem', InsertTextMenuItem: '#inserttextmenuitem', InsertTokenMenuItem: '#inserttokenmenuitem', CommandMenuItem: '#commandmenuitem', SubmenuTrigger: 'Menu#submenutrigger', Menu: 'Menu#menu', PromptFieldVoiceButton: '#promptfieldvoicebutton', PromptFieldSubmitButton: '#promptfieldsubmitbutton'}} + + + + + or or + + + + + + +``` -#### ThreadScrollButton + - +#### InsertMenuButton -#### UserMessage + - +#### AttachFileMenuItem -#### MessageFeedback + - +#### InsertTextMenuItem -#### MessageSource + - +#### InsertTokenMenuItem -#### SourceList + - +#### CommandMenuItem -#### SourceListItem + - +#### PromptFieldVoiceButton -#### ResponseStatus + - +#### PromptFieldSubmitButton -#### ResponseStatusTitle + - +## AttachmentList -#### ResponseStatusPanel +```tsx links={{Attachment: '#attachment', AttachmentList: '#attachmentlist', AttachmentPreview: '#attachmentpreview'}} + + {attachment => ( + + + + )} + +``` - + -#### ExecutionTrace +### Attachment - + -#### ExecutionTraceItem +### AttachmentPreview - + From 982c889455e8630cc2fec9370e5627b21a55e3f2 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 1 Sep 2026 14:50:38 +1000 Subject: [PATCH 21/27] fix alpha badge --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 949ddd440ac..95a34d7bb82 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -9,9 +9,9 @@ import docs from 'docs:@react-spectrum/ai'; export const section = 'Guides'; export const tags = ['spectrum', 'ai']; export const description = 'Build AI-powered experiences with prompts, messages, suggestions, attachments, and voice input.'; +export const version = 'alpha'; # AI Components - React Spectrum provides components for building AI-powered experiences, including prompts, messages, suggestions, attachments, and voice input. From 33f6aa6df0c760109254818704b1160b8347d0f2 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 1 Sep 2026 15:01:11 +1000 Subject: [PATCH 22/27] Add Alert to API --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 95a34d7bb82..9f54f3669d9 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -596,6 +596,10 @@ function BasicChat() { +### Alert + + + ### MessageSuggestionList ```tsx links={{MessageSuggestionList: '#messagesuggestionlist', MessageSuggestion: '#messagesuggestion'}} From 795642bce5d4aae93442647bc50c064f704938eb Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 1 Sep 2026 15:14:03 +1000 Subject: [PATCH 23/27] make promptfield smaller in example with control --- .../dev/s2-docs/pages/s2/ai-components.mdx | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 9f54f3669d9..9cfa054fbbf 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -247,27 +247,39 @@ import { PromptFieldValue, PromptTokenField } from '@react-spectrum/ai'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; function BasicPrompt(props) { let [value, setValue] = useState(() => new PromptFieldValue([])); return ( - { - console.log(value.toString()); - setValue(new PromptFieldValue([])); - }}> - - -
- -
-
-
+
+ { + console.log(value.toString()); + setValue(new PromptFieldValue([])); + }}> + + +
+ +
+
+
+
); } ``` From 394666fb06996aa3f872aa453de341b62af7123a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 1 Sep 2026 15:16:41 +1000 Subject: [PATCH 24/27] fix Chat example height and background color --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 9cfa054fbbf..02f000de4d6 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -485,12 +485,11 @@ function BasicChat() { styles={style({ display: 'flex', flexDirection: 'column', - height: 240, + height: 260, width: 'full', overflow: 'hidden', boxSizing: 'border-box', paddingX: 4, - backgroundColor: 'layer-2', minWidth: 0 })}>
Date: Tue, 1 Sep 2026 15:23:41 +1000 Subject: [PATCH 25/27] add icon for summarize menu item --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 02f000de4d6..57f8702f829 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -404,6 +404,8 @@ import { PromptFieldValue, PromptTokenField } from '@react-spectrum/ai'; +import {Text} from '@react-spectrum/s2'; +import CommentText from '@react-spectrum/s2/icons/CommentText'; function PromptAttachments() { let [attachments, setAttachments] = useState([]); @@ -426,7 +428,8 @@ function PromptAttachments() { - Summarize image + + Summarize image
From 290620db11e577a0bc5e6e1ecb10b3b5f5906190 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 1 Sep 2026 08:44:43 -0400 Subject: [PATCH 26/27] updates --- .../@react-spectrum/ai/src/AttachmentList.tsx | 1 + .../@react-spectrum/ai/src/PromptField.tsx | 1 + .../pages/s2/ai-component-helpers/chat.tsx | 4 +- .../dev/s2-docs/pages/s2/ai-components.mdx | 136 +++++++++++------- 4 files changed, 87 insertions(+), 55 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 5bff7ab488c..abd11abc77b 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -320,6 +320,7 @@ const flexRow = { const tagListStyles = style({ ...flexRow, + flexGrow: 1, gap: 8, overflowX: 'auto', overflowY: 'clip', diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 8ee1defa9da..a0d6286e989 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -98,6 +98,7 @@ export interface PromptFieldProps { onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; onAITermsPress?: () => void; styles?: StyleString; + /** @default 'balanced' */ variant?: 'balanced' | 'prominent' | 'subtle'; brandColor?: string; /** diff --git a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx index 67ad2bc8129..a4a8231f9da 100644 --- a/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx +++ b/packages/dev/s2-docs/pages/s2/ai-component-helpers/chat.tsx @@ -66,7 +66,7 @@ type StreamingMessage = | {id: number | string; type: 'suggestions'; suggestions: TokenFieldValue[]}; export interface VirtualizedStreamingChatProps { - children: (onSend: (prompt: TokenFieldValue) => void) => ReactNode; + children: (onSend: (prompt: TokenFieldValue) => void, isGenerating: boolean) => ReactNode; /** Suggestions shown at the end of the thread. Hidden while a response is streaming in. */ suggestions?: TokenFieldValue[]; onSelectSuggestion?: (suggestion: TokenFieldValue) => void; @@ -318,7 +318,7 @@ export function VirtualizedStreamingChat(props: VirtualizedStreamingChatProps) { }}
- {children(handleSend)} + {children(handleSend, isGenerating)}
); diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 57f8702f829..6ce487ecf19 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -81,19 +81,22 @@ function Example() { return (
+ {/*- begin focus -*/} { setValue(value as PromptFieldValue); promptFieldRef.current?.focus(); }}> - {onSend => ( + {/*- end focus -*/} + {(onSend, isGenerating) => ( { onSend(prompt); setValue(new PromptFieldValue([])); @@ -237,7 +240,7 @@ AI components are published as a separate package from `@react-spectrum/s2`. Use `PromptField` as the foundation for allowing the user to submit a prompt. It manages an editable sequence of text and tokens, while `PromptTokenField` renders the input and `PromptFieldToolbar` contains its actions. -```tsx render type="s2" docs={docs.exports.PromptField} props={['variant', 'size']} +```tsx render wide type="s2" docs={docs.exports.PromptField} props={['variant', 'size', 'isGenerating']} "use client"; import {useState} from 'react'; import { @@ -257,12 +260,13 @@ function BasicPrompt(props) { className={style({ minWidth: 190, width: { + default: 'full', size: { - default: 'full', S: '50%' } } })({size: props.size})}> + {/*- begin highlight -*/} - + {/*- end highlight -*/} +
@@ -354,6 +359,7 @@ function PromptSuggestions() { people .filter(person => person.toLowerCase().includes(filterValue.slice(1).toLowerCase())) @@ -370,6 +376,7 @@ function PromptSuggestions() { )) } + /*- end highlight -*/ placeholder="Ask about @customers" />
@@ -384,7 +391,7 @@ function PromptSuggestions() { ### Attachments and actions -Add attachments and place common actions in the toolbar. Use a custom attachment render function when an attachment needs a thumbnail or additional metadata. +Use `PromptFieldAttachmentList` to render a preview of attached files the user has dragged onto the PromptField or added via the `InsertMenuButton` in the toolbar. Custom menu items and toolbar controls can be added as well. ```tsx render type="s2" "use client"; @@ -408,21 +415,26 @@ import {Text} from '@react-spectrum/s2'; import CommentText from '@react-spectrum/s2/icons/CommentText'; function PromptAttachments() { - let [attachments, setAttachments] = useState([]); + let [attachments, setAttachments] = useState([ + {id: '0', file: new File([], 'preview.png', {type: 'image/png'}), image: 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'}, + {id: '1', file: new File([], 'notes.txt', {type: 'text/plain'}), image: ''} + ]); return ( + acceptedAttachmentTypes={['*/*']}> + {/*- begin highlight -*/} {attachment => ( - + )} + {/*- end highlight -*/} @@ -444,11 +456,11 @@ function PromptAttachments() { ## Chat threads -Compose a conversation from `Chat`, `Thread`, and message components. The thread can be driven by a collection as messages arrive from your application. +Compose a conversation from `Chat`, `Thread`, `ResponseStatus`, and message components. The thread can be driven by a collection as messages arrive from your application. ```tsx render type="s2" "use client"; -import {Chat, Thread, ThreadItem, UserMessage} from '@react-spectrum/ai'; +import {Chat, Thread, ThreadItem, UserMessage, ResponseStatus, ResponseStatusTitle, ResponseStatusPanel, ExecutionTrace, ExecutionTraceItem} from '@react-spectrum/ai'; import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -456,6 +468,16 @@ let messages = [ {id: 1, type: 'user', text: 'Summarize the campaign results.'}, { id: 2, + type: 'status', + status: 'success' as const, + text: 'Response complete', + steps: [ + {id: 1, label: 'Fetching campaign', status: 'success' as const, detail: 'Loaded campaign details and fetched engagement results from the database.'}, + {id: 2, label: 'Summarizing results', status: 'success' as const, detail: 'Created a full channel report and summarized the results.'} + ] + }, + { + id: 3, type: 'assistant', text: 'Engagement increased 18% this month, led by email and social. See the full report for a channel breakdown.', content: ( @@ -465,9 +487,9 @@ let messages = [ ) }, - {id: 3, type: 'user', text: 'Which channel performed best?'}, + {id: 4, type: 'user', text: 'Which channel performed best?'}, { - id: 4, + id: 5, type: 'assistant', text: 'Email drove the most conversions, with social close behind: Email with 4,200 conversions, Social with 3,100 conversions.', content: ( @@ -484,50 +506,58 @@ let messages = [ function BasicChat() { return ( + /*- begin highlight -*/ -
+ - - {message => - message.type === 'user' ? ( - - {message.text} - - ) : ( - -
{message.content}
-
- ) + {message => { + switch (message.type) { + case 'user': + return ( + + {message.text} + + ); + case 'assistant': + return ( + +
{message.content}
+
+ ); + case 'status': + return ( + + + {message.text} + + + {message.steps?.map(step => ( + {step.detail}

}> + {step.label} +
+ ))} +
+
+
+
+ ); } -
-
+ }} +
); } From 788aedd7886c2b4fb1f19989c4daf9fa3fafd9df Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 1 Sep 2026 09:02:14 -0400 Subject: [PATCH 27/27] more missing descriptions --- packages/@react-spectrum/ai/src/Alert.tsx | 3 + .../@react-spectrum/ai/src/AttachmentList.tsx | 6 ++ packages/@react-spectrum/ai/src/Chat.tsx | 12 +++ .../@react-spectrum/ai/src/PromptField.tsx | 33 +++++++ .../dev/s2-docs/pages/s2/ai-components.mdx | 86 +++++++------------ 5 files changed, 86 insertions(+), 54 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Alert.tsx b/packages/@react-spectrum/ai/src/Alert.tsx index 97e2e979d25..9ffd170b933 100644 --- a/packages/@react-spectrum/ai/src/Alert.tsx +++ b/packages/@react-spectrum/ai/src/Alert.tsx @@ -79,6 +79,9 @@ const text = style({ minWidth: 0 }); +/** + * An Alert shows an error message within a Chat thread or PromptField. + */ export const Alert = forwardRef(function Alert(props: AlertProps, ref: DOMRef) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let {children, variant = 'neutral', onDismiss, styles} = props; diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index abd11abc77b..d19efd7ed33 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -606,6 +606,9 @@ function AttachmentCard({ ); } +/** + * Attachment displays an individual file attachment within a PromptFieldAttachmentList. + */ export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -670,6 +673,9 @@ export interface AttachmentPreviewProps extends ImageProps { mimeType: string; } +/** + * AttachmentPreview renders a preview of a file attachment. + */ export function AttachmentPreview(props: AttachmentPreviewProps) { let {mimeType, ...otherProps} = props; let {isInvalid, uploadProgress, size} = useContext(AttachmentPreviewContext)!; diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 8c1b354f22b..869d8b15549 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -252,6 +252,9 @@ export interface ThreadProps extends Pick< scrollEndThreshold?: number; } +/** + * A Thread shows a conversation within a Chat. + */ export function Thread(props: ThreadProps) { let { items, @@ -327,6 +330,9 @@ export interface ThreadScrollButtonProps { // TODO: wrapper so we can do the "if isNearBottom then hide" logic, could do this via inline styles perhaps // and ditch the wrapper? +/** + * A ThreadScrollButton displays a button to scroll to the bottom of a Chat thread. + */ export function ThreadScrollButton({children}: ThreadScrollButtonProps) { let {isNearBottom, scrollToBottom, ...buttonProps} = useContext(ThreadScrollButtonContext); let ref = useRef(null); @@ -381,6 +387,9 @@ export interface ThreadItemProps extends Pick< shouldAnnounceOnMount?: boolean; } +/** + * A ThreadItem displays an individual chat message. + */ export function ThreadItem(props: ThreadItemProps) { let { styles, @@ -432,6 +441,9 @@ 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 +/** + * A ThreadLoadMoreItem loads more chat messages when it is scrolled into the viewport. + */ export const ThreadLoadMoreItem = createLeafComponent( LoaderNode, function GridListLoadingIndicator( diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index a0d6286e989..4fee489e00f 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -394,6 +394,9 @@ export interface PromptFieldAttachmentListProps extends AttachmentListProps React.ReactNode; } +/** + * PromptFieldAttachmentList displays a list of file attachments within a PromptField. + */ export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) { let {children} = props; let {attachments, setAttachments, onRemoveAttachments, inputRef} = useContext(PromptFieldContext); @@ -441,6 +444,10 @@ export interface PromptTokenFieldProps { menuWidth?: number; } +/** + * PromptTokenField renders an editable text input for a prompt, and supports inserting inline + * object references as tokens via autocomplete. + */ export function PromptTokenField(props: PromptTokenFieldProps) { let { completionTrigger, @@ -797,6 +804,9 @@ export interface PromptTokenProps extends Omit void; } +/** + * PromptFieldVoiceButton triggers voice input for the PromptField. + */ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { let {lang: langProp, isDisabled: isDisabledProp, onError, onToggle} = props; let {locale} = useLocale(); @@ -1013,6 +1030,9 @@ export interface InsertMenuItemProps extends Pick {} +/** + * AttachFileMenuItem triggers a system file dialog to attach files within an InsertMenuButton. + */ export function AttachFileMenuItem(props: AttachFileMenuItemProps) { let {onAction, ...otherProps} = props; let {acceptedAttachmentTypes, setAttachments, onAddAttachments} = useContext(PromptFieldContext); @@ -1149,6 +1172,10 @@ export interface InsertTokenMenuItemProps extends Omit< token: TokenSegment; } +/** + * InsertTokenMenuItem inserts a token (i.e. object reference) into the PromptField within an + * InsertMenuButton. + */ export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) { let insert = useInsertPromptSegment([props.token]); @@ -1180,6 +1207,9 @@ export interface InsertTextMenuItemProps extends Omit< text: string; } +/** + * InsertTextMenuItem inserts plain text into the PromptField from within an InsertMenuButton. + */ export function InsertTextMenuItem(props: InsertTextMenuItemProps) { let insert = useInsertPromptSegment([{type: 'text', text: props.text}]); @@ -1210,6 +1240,9 @@ export interface CommandMenuItemProps extends Omit< // specifically for menu items that only trigger a callback in the autocomplete menu // since they dont end up inserting a token or text, we need to clear the partial text that the user used // to filter the menu +/** + * CommandMenuItem performs an immediate action from within an InsertMenuButton. + */ export function CommandMenuItem(props: CommandMenuItemProps) { let insert = useInsertPromptSegment([]); return ( diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 6ce487ecf19..4aa5489c515 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -577,11 +577,11 @@ function BasicChat() { ## Chat - + ### ThreadScrollButton - + ## Thread @@ -595,19 +595,19 @@ function BasicChat() { ``` - + ### ThreadItem - + ### ThreadLoadMoreItem - + ### UserMessage - + ### ResponseStatus @@ -622,27 +622,27 @@ function BasicChat() { ``` - + #### ResponseStatusTitle - + #### ResponseStatusPanel - + #### ExecutionTrace - + #### ExecutionTraceItem - + ### Alert - + ### MessageSuggestionList @@ -652,15 +652,15 @@ function BasicChat() { ``` - + #### MessageSuggestion - + ### MessageFeedback - + ### MessageSource @@ -672,15 +672,15 @@ function BasicChat() { ``` - + #### SourceList - + #### SourceListItem - + ## PromptField @@ -692,7 +692,7 @@ function BasicChat() {
``` - + ### PromptFieldAttachmentList @@ -706,15 +706,15 @@ function BasicChat() { ``` - + #### Attachment - + #### AttachmentPreview - + ### PromptTokenField @@ -724,11 +724,11 @@ function BasicChat() { ``` - + #### PromptToken - + ### PromptFieldToolbar @@ -746,54 +746,32 @@ function BasicChat() { ``` - + #### InsertMenuButton - + #### AttachFileMenuItem - + #### InsertTextMenuItem - + #### InsertTokenMenuItem - + #### CommandMenuItem - + #### PromptFieldVoiceButton - + #### PromptFieldSubmitButton - - -## AttachmentList - -```tsx links={{Attachment: '#attachment', AttachmentList: '#attachmentlist', AttachmentPreview: '#attachmentpreview'}} - - {attachment => ( - - - - )} - -``` - - - -### Attachment - - - -### AttachmentPreview - - +