diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 17c2a8408f5..cacb63c5b52 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -17,10 +17,24 @@ export { PromptToken, PromptFieldVoiceButton } from '../src/PromptField'; -export {ResponseStatus, ResponseStatusTitle, ResponseStatusPanel} from '../src/ResponseStatus'; -export {Chat, Thread, ThreadItem, ThreadScrollButton, PromptFocusContext} from '../src/Chat'; +export { + ExecutionTrace, + ExecutionTraceItem, + ResponseStatus, + ResponseStatusTitle, + ResponseStatusPanel +} from '../src/ResponseStatus'; +export { + Chat, + Thread, + ThreadItem, + ThreadLoadMoreItem, + ThreadScrollButton, + PromptFocusContext +} from '../src/Chat'; export {TokenFieldValue} from 'react-aria-components/TokenField'; export {UserMessage} from '../src/UserMessage'; +export {PixelLoader} from '../src/loader/react'; export type {AttachmentProps, AttachmentListProps} from '../src/AttachmentList'; export type { @@ -39,10 +53,19 @@ export type {MessageFeedbackProps} from '../src/MessageFeedback'; export type {MessageSourceProps, SourceListProps, SourceListItemProps} from '../src/MessageSource'; export type {MessageSuggestionProps, MessageSuggestionListProps} from '../src/MessageSuggestion'; export type { + ExecutionTraceProps, + ExecutionTraceItemProps, ResponseStatusProps, ResponseStatusTitleProps, ResponseStatusPanelProps } from '../src/ResponseStatus'; -export type {ChatProps, ThreadProps, ThreadItemProps, ThreadScrollButtonProps} from '../src/Chat'; +export type { + ChatProps, + ThreadProps, + ThreadItemProps, + ThreadLoadMoreItemProps, + ThreadScrollButtonProps +} from '../src/Chat'; export type {TokenFieldValueOptions} from 'react-aria-components/TokenField'; export type {UserMessageProps} from '../src/UserMessage'; +export type {PixelLoaderProps} from '../src/loader/react'; diff --git a/packages/@react-spectrum/ai/exports/loader.ts b/packages/@react-spectrum/ai/exports/loader.ts index 4c0c853ec7d..b779837b645 100644 --- a/packages/@react-spectrum/ai/exports/loader.ts +++ b/packages/@react-spectrum/ai/exports/loader.ts @@ -1 +1,4 @@ +export {PixelLoader} from '../src/loader/react'; export * from '../src/loader/data'; + +export type {PixelLoaderProps} from '../src/loader/react'; diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index ec6bef5761f..d17c593513b 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -12,34 +12,49 @@ import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {ButtonContext} from 'react-aria-components/Button'; +import { + CollectionRendererContext, + createLeafComponent +} from 'react-aria-components/CollectionBuilder'; import { createContext, + ForwardedRef, forwardRef, ReactNode, RefObject, useCallback, useContext, useEffect, + useMemo, useRef, useState } from 'react'; import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; -import {DOMRef, forwardRefType} from '@react-types/shared'; +import {DOMRef, forwardRefType, Node} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; import {focusRing, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { GridList, GridListItem, GridListItemProps, + GridListLoadMoreItemProps, GridListProps } from 'react-aria-components/GridList'; +import {inertValue} from 'react-aria/private/utils/inertValue'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {ListLayout} from './ListLayout'; +import {ListStateContext} from 'react-aria-components/ListBox'; +import {LoaderNode} from 'react-aria/private/collections/BaseCollection'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {useDOMRef} from './useDOMRef'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useFocusWithin} from 'react-aria/useFocusWithin'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {useLoadMoreSentinel} from 'react-aria/private/utils/useLoadMoreSentinel'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useRenderProps} from 'react-aria-components/useRenderProps'; +import {Virtualizer} from 'react-aria-components/Virtualizer'; const scrollButtonWrapper = style({ opacity: { @@ -137,7 +152,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( }, {once: true} ); - el.scrollTo({top: 0, behavior: 'smooth'}); + el.scrollTo({top: el.scrollHeight - el.clientHeight, behavior: 'smooth'}); }, []); let [isNearBottom, setIsNearBottom] = useState(true); @@ -217,12 +232,20 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( export interface ThreadProps extends Pick< GridListProps, - 'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby' + 'items' | 'children' | 'aria-label' | 'aria-labelledby' > { /** * Spectrum-defined styles, returned by the `style()` macro. */ styles?: StyleString; + /** + * The maximum distance in px from the bottom of the content for the + * viewport to be considered "near the end". While near the end, appended content and streaming + * size changes will keep the viewport pinned to the latest output. + * + * @default 100 + */ + scrollEndThreshold?: number; } export function Thread(props: ThreadProps) { @@ -230,7 +253,7 @@ export function Thread(props: ThreadProps) { items, children, styles, - UNSTABLE_focusOnEntry, + scrollEndThreshold = 100, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } = props; @@ -238,7 +261,6 @@ export function Thread(props: ThreadProps) { let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext); let isNearBottomRef = useRef(true); let gridListRef = useRef(null); - let callbackRef = useCallback( (el: HTMLDivElement | null) => { gridListRef.current = el; @@ -253,45 +275,42 @@ export function Thread(props: ThreadProps) { return; } - // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up - let nearBottom = el.scrollTop > -100; + let nearBottom = el.scrollTop >= el.scrollHeight - el.clientHeight - scrollEndThreshold; isNearBottomRef.current = nearBottom; setIsNearBottom(nearBottom); - }, [setIsNearBottom]); - - useEffect(() => { - // scrolls to bottom on first render cuz we initialize isNearBottomRef to true, - // otherwise handles scrolling new prompts/etc into view unless you are scrolled up above - // 100px - if (isNearBottomRef.current) { - requestAnimationFrame(() => { - if (gridListRef.current) { - gridListRef.current.scrollTop = 0; - } - }); - } - }, [items]); + }, [setIsNearBottom, scrollEndThreshold]); return ( - - {children} - + shouldObserveItemSize> + + {children} + + ); } @@ -401,3 +420,60 @@ export function ThreadItem(props: ThreadItemProps) { ); } + +export interface ThreadLoadMoreItemProps extends GridListLoadMoreItemProps {} + +// TODO: Reuse GridListLoadMoreItem instead when Thread component moves into RAC. +// Re-implementing here so we can avoid passing 'direction' to the LoadMore item +export const ThreadLoadMoreItem = createLeafComponent( + LoaderNode, + function GridListLoadingIndicator( + props: GridListLoadMoreItemProps, + ref: ForwardedRef, + item: Node + ) { + let state = useContext(ListStateContext)!; + let direction: 'start' | 'end' | undefined = 'start'; + let {isVirtualized} = useContext(CollectionRendererContext); + let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; + + let sentinelRef = useRef(null); + let memoedLoadMoreProps = useMemo( + () => ({onLoadMore, collection: state?.collection, scrollOffset, direction}), + [onLoadMore, scrollOffset, state?.collection, direction] + ); + useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + + let renderProps = useRenderProps({ + ...otherProps, + id: undefined, + children: item.rendered, + defaultClassName: 'react-aria-GridListLoadingIndicator', + values: undefined + }); + // For now don't include aria-posinset and aria-setsize on loader since they aren't keyboard focusable + // Arguably shouldn't include them ever since it might be confusing to the user to include the loaders as part of the + // item count + + return ( + <> + {/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */} + {/* @ts-ignore - compatibility with React < 19 */} +
+
+
+ {isLoading && renderProps.children && ( +
+
+ {renderProps.children} +
+
+ )} + + ); + } +); diff --git a/packages/@react-spectrum/ai/src/ListLayout.ts b/packages/@react-spectrum/ai/src/ListLayout.ts new file mode 100644 index 00000000000..f0dbca74df4 --- /dev/null +++ b/packages/@react-spectrum/ai/src/ListLayout.ts @@ -0,0 +1,1111 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + Collection, + DropTarget, + DropTargetDelegate, + ItemDropTarget, + Key, + Node, + Orientation +} from '@react-types/shared'; +import {getChildNodes} from 'react-stately/private/collections/getChildNodes'; +import { + InvalidationContext, + Layout, + LayoutInfo, + Rect, + Size +} from 'react-stately/useVirtualizerState'; + +const isLoaderAnchorable = (layoutInfo: LayoutInfo): boolean => layoutInfo.type !== 'loader'; + +export interface ScrollAnchorInfo { + /** Which edge of the content the viewport should stay anchored to. */ + edge: 'start' | 'end'; + /** Which axis `edge` refers to — 'y' for vertical lists, 'x' for horizontal. */ + axis: 'x' | 'y'; + /** Distance (px) from `edge` within which the viewport is considered "following" it. */ + threshold: number; + /** + * Optional classifier excluding structural/ephemeral layout infos (e.g. loaders) from being + * selected as the anchor. Defaults to allowing any layoutInfo. + */ + isAnchorable?: (layoutInfo: LayoutInfo) => boolean; +} + +export interface ListLayoutOptions { + /** + * Anchors the vertical list content to the end (bottom) of the viewport. When set to `'end'`, + * the viewport stays pinned to the latest content unless the user scrolls up. + */ + anchorTo?: 'end'; + /** + * The maximum distance in px from the anchored edge of the content for the viewport to be + * considered "near the end". While near the end, appended content and streaming size changes + * will keep the viewport pinned to `anchorTo`. + * + * @default 0 + */ + scrollEndThreshold?: number; + /** + * The primary orientation of the items. Usually this is the direction that the collection + * scrolls. + * + * @default 'vertical' + */ + orientation?: Orientation; + /** + * The fixed size of a row in px with respect to the applied orientation. + * + * @default 48 + */ + rowSize?: number; + /** + * The estimated size of a row in px with respect to the applied orientation, when row sizes are + * variable. + */ + estimatedRowSize?: number; + /** + * The fixed size of a section header in px with respect to the applied orientation. + * + * @default 48 + */ + headingSize?: number; + /** + * The estimated size of a section header in px with respect to the applied orientation, when + * heading sizes are variable. + */ + estimatedHeadingSize?: number; + /** + * The fixed size of a loader element in px with respect to the applied orientation. This loader + * is specifically for "load more" elements rendered when loading more rows at the root level or + * inside nested row/sections. + * + * @default 48 + */ + loaderSize?: number; + /** + * The thickness of the drop indicator. + * + * @default 2 + */ + dropIndicatorThickness?: number; + /** + * The gap between items. + * + * @default 0 + */ + gap?: number; + /** + * The padding around the list. + * + * @default 0 + */ + padding?: number; + /** + * The fixed height of a row in px. + * + * @deprecated Use `rowSize` instead. + * @default 48 + */ + rowHeight?: number; + /** + * The estimated height of a row, when row heights are variable. + * + * @deprecated Use `estimatedRowSize` instead. + */ + estimatedRowHeight?: number; + /** + * The fixed height of a section header in px. + * + * @deprecated Use `headingSize` instead. + * @default 48 + */ + headingHeight?: number; + /** + * The estimated height of a section header, when the height is variable. + * + * @deprecated Use `estimatedHeadingSize` instead. + */ + estimatedHeadingHeight?: number; + /** + * The fixed height of a loader element in px. This loader is specifically for "load more" + * elements rendered when loading more rows at the root level or inside nested row/sections. + * + * @deprecated Use `loaderSize` instead. + * @default 48 + */ + loaderHeight?: number; +} + +// A wrapper around LayoutInfo that supports hierarchy +export interface LayoutNode { + node?: Node; + layoutInfo: LayoutInfo; + children?: LayoutNode[]; + validRect: Rect; + index?: number; +} + +const DEFAULT_HEIGHT = 48; + +/** + * ListLayout is a virtualizer Layout implementation + * that arranges its items in a stack along its applied orientation. + * It supports both fixed and variable size items. + */ +export class ListLayout + extends Layout, O> + implements DropTargetDelegate +{ + protected rowSize: number | null; + protected orientation: Orientation; + protected estimatedRowSize: number | null; + protected headingSize: number | null; + protected estimatedHeadingSize: number | null; + protected loaderSize: number | null; + protected dropIndicatorThickness: number; + protected gap: number; + protected padding: number; + protected anchorTo: 'end' | undefined; + protected scrollEndThreshold: number; + protected layoutNodes: Map; + protected contentSize: Size; + protected lastCollection: Collection> | null; + protected rootNodes: LayoutNode[]; + private invalidateEverything: boolean; + /** The rectangle containing currently valid layout infos. */ + protected validRect: Rect; + /** The rectangle of requested layout infos so far. */ + protected requestedRect: Rect; + + /** + * Creates a new ListLayout with options. See the list of properties below for a description + * of the options that can be provided. + */ + constructor(options: ListLayoutOptions = {}) { + super(); + this.anchorTo = options.anchorTo; + this.scrollEndThreshold = options.scrollEndThreshold ?? 0; + this.rowSize = options?.rowSize ?? options?.rowHeight ?? null; + this.orientation = options.orientation ?? 'vertical'; + this.estimatedRowSize = options?.estimatedRowSize ?? options?.estimatedRowHeight ?? null; + this.headingSize = options?.headingSize ?? options?.headingHeight ?? null; + this.estimatedHeadingSize = + options?.estimatedHeadingSize ?? options?.estimatedHeadingHeight ?? null; + this.loaderSize = options?.loaderSize ?? options?.loaderHeight ?? null; + this.dropIndicatorThickness = options.dropIndicatorThickness || 2; + this.gap = options.gap || 0; + this.padding = options.padding || 0; + this.layoutNodes = new Map(); + this.rootNodes = []; + this.lastCollection = null; + this.invalidateEverything = false; + this.validRect = new Rect(); + this.requestedRect = new Rect(); + this.contentSize = new Size(); + this.warnIfReversedHorizontal(); + } + + private warnIfReversedHorizontal(): void { + if ( + this.anchorTo === 'end' && + this.orientation === 'horizontal' && + process.env.NODE_ENV !== 'production' + ) { + console.warn( + 'ListLayout: anchorTo="end" is only supported in vertical orientations and will be ignored in horizontal orientation.' + ); + } + } + + UNSTABLE_getScrollAnchorInfo(layoutOptions?: O): ScrollAnchorInfo | null { + let anchorTo = layoutOptions?.anchorTo ?? this.anchorTo; + let orientation = layoutOptions?.orientation ?? this.orientation; + // TODO: Reversed (anchorTo: 'end') layouts are only supported in vertical orientations (for now). + if (anchorTo !== 'end' || orientation === 'horizontal') { + return null; + } + let threshold = layoutOptions?.scrollEndThreshold ?? this.scrollEndThreshold; + return {edge: 'end', axis: 'y', threshold, isAnchorable: isLoaderAnchorable}; + } + + // Backward compatibility for subclassing. + protected get collection(): Collection> { + return this.virtualizer!.collection; + } + + /** @deprecated Use `rowSize` instead. */ + protected get rowHeight(): number | null { + return this.rowSize; + } + + /** @deprecated Use `estimatedRowSize` instead. */ + protected get estimatedRowHeight(): number | null { + return this.estimatedRowSize; + } + + /** @deprecated Use `headingSize` instead. */ + protected get headingHeight(): number | null { + return this.headingSize; + } + /** @deprecated Use `estimatedHeadingSize` instead. */ + protected get estimatedHeadingHeight(): number | null { + return this.estimatedHeadingSize; + } + + /** @deprecated Use `loaderSize` instead. */ + protected get loaderHeight(): number | null { + return this.loaderSize; + } + + getLayoutInfo(key: Key): LayoutInfo | null { + this.ensureLayoutInfo(key); + return this.layoutNodes.get(key)?.layoutInfo || null; + } + + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + + // Adjust rect to keep number of visible rows consistent. + // (only if height > 1 or width > 1 for getDropTargetFromPoint) + if (rect[heightProperty] > 1) { + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; + // Clone only before mutating + rect = rect.copy(); + let offset = Math.floor(rect[offsetProperty] / rowHeight) * rowHeight; + let height = rect[heightProperty] + rect[offsetProperty] - offset; + rect[offsetProperty] = offset; + rect[heightProperty] = Math.ceil(height / rowHeight) * rowHeight; + } + + // If layout hasn't yet been done for the requested rect, union the + // new rect with the existing valid rect, and recompute. + this.layoutIfNeeded(rect); + + let res: LayoutInfo[] = []; + + let addNodes = (nodes: LayoutNode[]) => { + for (let node of nodes) { + if (this.isVisible(node, rect)) { + res.push(node.layoutInfo); + + if (node.children) { + addNodes(node.children); + } + } + } + }; + + addNodes(this.rootNodes); + return res; + } + + protected layoutIfNeeded(rect: Rect): void { + if (!this.lastCollection) { + return; + } + + if (!this.requestedRect.containsRect(rect)) { + this.requestedRect = this.requestedRect.union(rect); + this.rootNodes = this.buildCollection(); + } + + // Ensure all of the persisted keys are available. + for (let key of this.virtualizer!.persistedKeys) { + if (this.ensureLayoutInfo(key)) { + return; + } + } + } + + private ensureLayoutInfo(key: Key) { + // If the layout info wasn't found, it might be outside the bounds of the area that we've + // computed layout for so far. This can happen when accessing a random key, e.g pressing Home/End. + // Compute the full layout and try again. + if ( + !this.layoutNodes.has(key) && + this.requestedRect.area < this.contentSize.area && + this.lastCollection + ) { + this.requestedRect = new Rect(0, 0, Infinity, Infinity); + this.rootNodes = this.buildCollection(); + this.requestedRect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); + return true; + } + + return false; + } + + protected isVisible(node: LayoutNode, rect: Rect): boolean { + return ( + node.layoutInfo.rect.intersects(rect) || + node.layoutInfo.isSticky || + node.layoutInfo.type === 'header' || + node.layoutInfo.type === 'loader' || + this.virtualizer!.isPersistedKey(node.layoutInfo.key) + ); + } + + protected shouldInvalidateEverything(invalidationContext: InvalidationContext): boolean { + // Invalidate cache if the cross-axis size of the collection changed (e.g. width, for a + // vertical list): that can change how items wrap, so cached row heights are no longer + // trustworthy. A change to only the main-axis size (e.g. height, for a vertical list) just + // means more or less of the list is visible, and doesn't affect any row's real height, so it + // shouldn't throw away the cache. + // Also invalidate if fixed sizes/gaps change. + let options = invalidationContext.layoutOptions; + let orientation = options?.orientation ?? this.orientation; + let crossAxisSizeChanged = + orientation === 'horizontal' + ? invalidationContext.heightChanged + : invalidationContext.widthChanged; + return ( + crossAxisSizeChanged || + this.rowSize !== (options?.rowSize ?? options?.rowHeight ?? this.rowSize) || + this.orientation !== (options?.orientation ?? this.orientation) || + this.anchorTo !== (options?.anchorTo ?? this.anchorTo) || + this.headingSize !== (options?.headingSize ?? options?.headingHeight ?? this.headingSize) || + this.loaderSize !== (options?.loaderSize ?? options?.loaderHeight ?? this.loaderSize) || + this.gap !== (options?.gap ?? this.gap) || + this.padding !== (options?.padding ?? this.padding) + ); + } + + shouldInvalidateLayoutOptions(newOptions: O, oldOptions: O): boolean { + return ( + (newOptions?.rowSize ?? newOptions?.rowHeight) !== + (oldOptions?.rowSize ?? oldOptions?.rowHeight) || + newOptions.orientation !== oldOptions.orientation || + newOptions.anchorTo !== oldOptions.anchorTo || + (newOptions?.estimatedRowSize ?? newOptions?.estimatedRowHeight) !== + (oldOptions?.estimatedRowSize ?? oldOptions?.estimatedRowHeight) || + (newOptions?.headingSize ?? newOptions?.headingHeight) !== + (oldOptions?.headingSize ?? oldOptions?.headingHeight) || + (newOptions?.estimatedHeadingSize ?? newOptions?.estimatedHeadingHeight) !== + (oldOptions?.estimatedHeadingSize ?? oldOptions?.estimatedHeadingHeight) || + (newOptions?.loaderSize ?? newOptions?.loaderHeight) !== + (oldOptions?.loaderSize ?? oldOptions?.loaderHeight) || + newOptions.dropIndicatorThickness !== oldOptions.dropIndicatorThickness || + newOptions.gap !== oldOptions.gap || + newOptions.padding !== oldOptions.padding || + newOptions.scrollEndThreshold !== oldOptions.scrollEndThreshold + ); + } + + update(invalidationContext: InvalidationContext): void { + let collection = this.virtualizer!.collection; + + // Reset valid rect if we will have to invalidate everything. + // Otherwise we can reuse cached layout infos outside the current visible rect. + this.invalidateEverything = this.shouldInvalidateEverything(invalidationContext); + if (this.invalidateEverything) { + this.requestedRect = this.virtualizer!.visibleRect.copy(); + this.layoutNodes.clear(); + } + + let options = invalidationContext.layoutOptions; + this.rowSize = options?.rowSize ?? options?.rowHeight ?? this.rowSize; + this.orientation = options?.orientation ?? this.orientation; + this.anchorTo = options?.anchorTo ?? this.anchorTo; + this.scrollEndThreshold = options?.scrollEndThreshold ?? this.scrollEndThreshold; + this.estimatedRowSize = + options?.estimatedRowSize ?? options?.estimatedRowHeight ?? this.estimatedRowSize; + this.headingSize = options?.headingSize ?? options?.headingHeight ?? this.headingSize; + this.estimatedHeadingSize = + options?.estimatedHeadingSize ?? options?.estimatedHeadingHeight ?? this.estimatedHeadingSize; + this.loaderSize = options?.loaderSize ?? options?.loaderHeight ?? this.loaderSize; + this.dropIndicatorThickness = options?.dropIndicatorThickness ?? this.dropIndicatorThickness; + this.gap = options?.gap ?? this.gap; + this.padding = options?.padding ?? this.padding; + this.warnIfReversedHorizontal(); + + this.rootNodes = this.buildCollection(); + + // Remove deleted layout nodes + if (this.lastCollection && collection !== this.lastCollection) { + for (let key of this.lastCollection.getKeys()) { + if (!collection.getItem(key)) { + let layoutNode = this.layoutNodes.get(key); + if (layoutNode) { + this.layoutNodes.delete(key); + } + } + } + } + + this.lastCollection = collection; + this.invalidateEverything = false; + this.validRect = this.requestedRect.copy(); + } + + protected buildCollection(offset: number = this.padding): LayoutNode[] { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + return this.buildReversedCollection(); + } + + let collection = this.virtualizer!.collection; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; + + // filter out content nodes since we don't want them to affect the height + // Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this + let collectionNodes = toArray(collection, node => node.type !== 'content'); + let loaderNodes = collectionNodes.filter(node => node.type === 'loader'); + let nodes: LayoutNode[] = []; + let isEmptyOrLoading = collection?.size === 0; + if (isEmptyOrLoading) { + offset = 0; + } + + for (let node of collectionNodes) { + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; + // Skip rows before the valid rectangle unless they are already cached. + if ( + node.type === 'item' && + offset + rowHeight < this.requestedRect[offsetProperty] && + !this.isValid(node, offset) + ) { + offset += rowHeight; + continue; + } + + let layoutNode = + this.orientation === 'horizontal' + ? this.buildChild(node, offset, this.padding, null) + : this.buildChild(node, this.padding, offset, null); + offset = layoutNode.layoutInfo.rect[maxOffsetProperty] + this.gap; + nodes.push(layoutNode); + if (node.type === 'loader') { + let index = loaderNodes.indexOf(node); + loaderNodes.splice(index, 1); + } + + // Build each loader that exists in the collection that is outside the visible rect so that they are persisted + // at the proper estimated location. If the node.type is "section" then we don't do this shortcut since we have to + // build the sections to see how tall they are. + if ( + (node.type === 'item' || node.type === 'loader') && + offset > this.requestedRect[maxOffsetProperty] + ) { + let lastProcessedIndex = collectionNodes.indexOf(node); + for (let loaderNode of loaderNodes) { + let loaderNodeIndex = collectionNodes.indexOf(loaderNode); + // Subtract by an additional 1 since we've already added the current item's height to y + offset += (loaderNodeIndex - lastProcessedIndex - 1) * rowHeight; + let loader = + this.orientation === 'horizontal' + ? this.buildChild(loaderNode, offset, this.padding, null) + : this.buildChild(loaderNode, this.padding, offset, null); + nodes.push(loader); + offset = loader.layoutInfo.rect[maxOffsetProperty]; + lastProcessedIndex = loaderNodeIndex; + } + + // Account for the rest of the items after the last loader spinner, subtract by 1 since we've processed the current node's height already + offset += (collectionNodes.length - lastProcessedIndex - 1) * rowHeight; + break; + } + } + + offset = Math.max(offset - this.gap, 0); + offset += isEmptyOrLoading ? 0 : this.padding; + let contentLength = offset; + this.contentSize = + this.orientation === 'horizontal' + ? new Size(offset, this.virtualizer!.size.height) + : new Size(this.virtualizer!.size.width, contentLength); + + return nodes; + } + + // TODO: promote to protected once the reversed layout API is more stable and tested + private buildReversedCollection(): LayoutNode[] { + let collectionNodes = toArray(this.virtualizer!.collection, node => node.type !== 'content'); + this.assertReversedCollectionSupported(collectionNodes); + + // Height-only pass: walk collectionNodes once, in collection order, to determine every + // node's height (items and loaders alike) + let heights = new Map, number>(); + let loaderHeightIsEstimated = new Map, boolean>(); + let visibleCount = 0; + let sumHeights = 0; + let anyLoader = false; + for (let node of collectionNodes) { + if (node.type === 'loader') { + anyLoader = true; + let height = 0; + let estimated = false; + if (node.props.isLoading) { + let cached = this.layoutNodes.get(node.key); + if (cached && !cached.layoutInfo.estimatedSize && cached.layoutInfo.rect.height > 0) { + height = cached.layoutInfo.rect.height; + } else { + height = this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT; + estimated = this.loaderSize == null && this.rowSize == null; + } + } + // Not loading: the sentinel is 0px tall and doesn't occupy space. + heights.set(node, height); + loaderHeightIsEstimated.set(node, estimated); + if (height > 0) { + visibleCount++; + sumHeights += height; + } + } else { + let cached = this.layoutNodes.get(node.key); + let height = + cached && !cached.layoutInfo.estimatedSize + ? cached.layoutInfo.rect.height + : (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT); + heights.set(node, height); + visibleCount++; + sumHeights += height; + } + } + + // Gap count only depends on the total number of visible slots (items + loaders with height > + // 0), not their arrangement, so this holds regardless of how items/loaders interleave. + let contentLength = sumHeights + Math.max(visibleCount - 1, 0) * this.gap; + if (visibleCount > 0 || anyLoader) { + contentLength += this.padding * 2; + } + + let contentHeight = Math.max(contentLength, this.virtualizer!.size.height); + this.contentSize = new Size(this.virtualizer!.size.width, contentHeight); + + // Iterate last → first so the last item in the collection (newest) is placed at the visual + // bottom and written to nodes[0] (first in DOM) for screen-reader accessibility. + let width = this.virtualizer!.size.width - this.padding * 2; + let nodes: LayoutNode[] = []; + let currentBottom = contentLength - this.padding; + + for (let i = collectionNodes.length - 1; i >= 0; i--) { + let node = collectionNodes[i]; + let height = heights.get(node)!; + let layoutNode: LayoutNode; + + if (node.type === 'loader') { + const sentinelYOffset = i === 0 && height === 0 && visibleCount > 0 ? this.gap : 0; + let loaderY = currentBottom - height + sentinelYOffset; + let loaderNode = this.buildNode(node, this.padding, loaderY); + loaderNode.layoutInfo.rect.height = height; + loaderNode.layoutInfo.parentKey = null; + loaderNode.layoutInfo.allowOverflow = true; + if (node.props.isLoading) { + loaderNode.layoutInfo.estimatedSize = loaderHeightIsEstimated.get(node)!; + } + loaderNode.validRect = loaderNode.layoutInfo.rect.intersection(this.requestedRect); + this.layoutNodes.set(loaderNode.layoutInfo.key, loaderNode); + layoutNode = loaderNode; + currentBottom = loaderY - this.gap; + } else { + let y = currentBottom - height; + let cached = this.layoutNodes.get(node.key); + + if (cached && !cached.layoutInfo.estimatedSize) { + let newLayoutInfo = cached.layoutInfo.copy(); + newLayoutInfo.rect.y = y; + layoutNode = {layoutInfo: newLayoutInfo, validRect: new Rect(0, 0, 0, 0), children: []}; + } else { + let itemRect = new Rect(this.padding, y, width, height); + if (itemRect.intersects(this.requestedRect)) { + layoutNode = this.buildNode(node, this.padding, y); + } else { + let layoutInfo = new LayoutInfo(node.type, node.key, itemRect); + layoutInfo.estimatedSize = true; + layoutNode = {layoutInfo, validRect: new Rect(0, 0, 0, 0), children: [], node}; + } + } + + layoutNode.layoutInfo.parentKey = null; + layoutNode.layoutInfo.allowOverflow = true; + layoutNode.validRect = layoutNode.layoutInfo.rect.intersection(this.requestedRect); + this.layoutNodes.set(layoutNode.layoutInfo.key, layoutNode); + currentBottom = y - this.gap; + } + + nodes.push(layoutNode); + } + + return nodes; + } + + protected isValid(node: Node, offset: number): boolean { + let cached = this.layoutNodes.get(node.key); + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + return ( + !this.invalidateEverything && + !!cached && + cached.node === node && + offset === cached.layoutInfo.rect[offsetProperty] && + cached.layoutInfo.rect.intersects(this.validRect) && + cached.validRect.containsRect(cached.layoutInfo.rect.intersection(this.requestedRect)) + ); + } + + protected buildChild(node: Node, x: number, y: number, parentKey: Key | null): LayoutNode { + if (this.isValid(node, this.orientation === 'horizontal' ? x : y)) { + return this.layoutNodes.get(node.key)!; + } + + let layoutNode = this.buildNode(node, x, y); + + layoutNode.layoutInfo.parentKey = parentKey ?? null; + layoutNode.layoutInfo.allowOverflow = true; + this.layoutNodes.set(node.key, layoutNode); + return layoutNode; + } + + protected buildNode(node: Node, x: number, y: number): LayoutNode { + switch (node.type) { + case 'section': + return this.buildSection(node, x, y); + case 'item': + return this.buildItem(node, x, y); + case 'header': + return this.buildSectionHeader(node, x, y); + case 'loader': + return this.buildLoader(node, x, y); + case 'separator': + return this.buildItem(node, x, y); + default: + throw new Error('Unsupported node type: ' + node.type); + } + } + + protected buildLoader(node: Node, x: number, y: number): LayoutNode { + let rect = new Rect(x, y, this.padding, 0); + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + + // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve + // room for the loader alongside rendering the emptyState + if (this.orientation === 'horizontal') { + rect.height = this.virtualizer!.contentSize.height - this.padding - y; + rect.width = node.props.isLoading + ? (this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + : 0; + } else { + rect.width = this.virtualizer!.contentSize.width - this.padding - x; + rect.height = node.props.isLoading + ? (this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + : 0; + } + + return { + layoutInfo, + validRect: rect.intersection(this.requestedRect) + }; + } + + protected buildSection(node: Node, x: number, y: number): LayoutNode { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + + let collection = this.virtualizer!.collection; + let width = this.virtualizer!.size.width - this.padding - x; + let height = this.virtualizer!.size.height - this.padding - y; + let rect = + this.orientation === 'horizontal' ? new Rect(x, y, 0, height) : new Rect(x, y, width, 0); + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + + let offset = this.orientation === 'horizontal' ? x : y; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + + let skipped = 0; + let children: LayoutNode[] = []; + for (let child of getChildNodes(node, collection)) { + // skip if it is a content node, Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this + if (child.type === 'content') { + continue; + } + + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; + + // Skip rows before the valid rectangle unless they are already cached. + if (offset + rowHeight < this.requestedRect[offsetProperty] && !this.isValid(node, offset)) { + offset += rowHeight; + skipped++; + continue; + } + + let layoutNode = + this.orientation === 'horizontal' + ? this.buildChild(child, offset, y, layoutInfo.key) + : this.buildChild(child, x, offset, layoutInfo.key); + offset = layoutNode.layoutInfo.rect[maxOffsetProperty] + this.gap; + children.push(layoutNode); + + if (offset > this.requestedRect[maxOffsetProperty]) { + // Estimate the remaining height for rows that we don't need to layout right now. + offset += + ([...getChildNodes(node, collection)].length - (children.length + skipped)) * rowHeight; + break; + } + } + + offset -= this.gap; + rect[heightProperty] = offset - (this.orientation === 'horizontal' ? x : y); + + return { + layoutInfo, + children, + validRect: layoutInfo.rect.intersection(this.requestedRect), + node + }; + } + + protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + + let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + let width = + this.virtualizer!.size[widthProperty] - + this.padding - + (this.orientation === 'horizontal' ? y : x); + let rectHeight = this.headingSize; + let isEstimated = false; + + // If no explicit height is available, use an estimated height. + if (rectHeight == null) { + // If a previous version of this layout info exists, reuse its height. + // Mark as estimated if the size of the overall virtualizer changed, + // or the content of the item changed. + let previousLayoutNode = this.layoutNodes.get(node.key); + let previousLayoutInfo = previousLayoutNode?.layoutInfo; + if (previousLayoutInfo) { + let curNode = this.virtualizer!.collection.getItem(node.key); + let lastNode = this.lastCollection ? this.lastCollection.getItem(node.key) : null; + rectHeight = previousLayoutNode!.layoutInfo.rect[heightProperty]; + isEstimated = + width !== previousLayoutInfo.rect[widthProperty] || + curNode !== lastNode || + previousLayoutInfo.estimatedSize; + } else { + rectHeight = node.rendered ? this.estimatedHeadingSize : 0; + isEstimated = true; + } + } + + if (rectHeight == null) { + rectHeight = DEFAULT_HEIGHT; + } + + let headerRect = + this.orientation === 'horizontal' + ? new Rect(x, y, rectHeight, width - y) + : new Rect(x, y, width - x, rectHeight); + let header = new LayoutInfo('header', node.key, headerRect); + header.estimatedSize = isEstimated; + return { + layoutInfo: header, + children: [], + validRect: header.rect.intersection(this.requestedRect), + node + }; + } + + protected buildItem(node: Node, x: number, y: number): LayoutNode { + let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + + let width = + this.virtualizer!.size[widthProperty] - + this.padding - + (this.orientation === 'horizontal' ? y : x); + let rectHeight = this.rowSize; + let isEstimated = false; + + // If no explicit height is available, use an estimated height. + if (rectHeight == null) { + // If a previous version of this layout info exists, reuse its height. + // Mark as estimated if the size of the overall virtualizer changed, + // or the content of the item changed. + let previousLayoutNode = this.layoutNodes.get(node.key); + if (previousLayoutNode) { + rectHeight = previousLayoutNode.layoutInfo.rect[heightProperty]; + isEstimated = + width !== previousLayoutNode.layoutInfo.rect[widthProperty] || + node !== previousLayoutNode.node || + previousLayoutNode.layoutInfo.estimatedSize; + } else { + rectHeight = this.estimatedRowSize; + isEstimated = true; + } + } + + if (rectHeight == null) { + rectHeight = DEFAULT_HEIGHT; + } + + let rect = + this.orientation === 'horizontal' + ? new Rect(x, y, rectHeight, width) + : new Rect(x, y, width, rectHeight); + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + layoutInfo.estimatedSize = isEstimated; + return { + layoutInfo, + children: [], + validRect: layoutInfo.rect.intersection(this.requestedRect), + node + }; + } + + updateItemSize(key: Key, size: Size): boolean { + let layoutNode = this.layoutNodes.get(key); + // If no layoutInfo, item has been deleted/removed. + if (!layoutNode) { + return false; + } + + let collection = this.virtualizer!.collection; + let layoutInfo = layoutNode.layoutInfo; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + layoutInfo.estimatedSize = false; + + // Store the real measured height and signal a relayout. Unlike the normal path, we don't + // adjust validRect/requestedRect here. In a bottom-up layout, each item's absolute y + // depends on contentLength, which requires summing all item heights first. + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + if (layoutInfo.rect[heightProperty] !== size[heightProperty]) { + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect[heightProperty] = size[heightProperty]; + newLayoutInfo.estimatedSize = false; + layoutNode.layoutInfo = newLayoutInfo; + this.layoutNodes.set(key, layoutNode); + return true; + } + + return false; + } + + if (layoutInfo.rect[heightProperty] !== size[heightProperty]) { + // Copy layout info rather than mutating so that later caches are invalidated. + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect[heightProperty] = size[heightProperty]; + layoutNode.layoutInfo = newLayoutInfo; + + // Items after this layoutInfo will need to be repositioned to account for the new height. + // Adjust the validRect so that only items above remain valid. + this.validRect[heightProperty] = Math.min( + this.validRect[heightProperty], + layoutInfo.rect[offsetProperty] - this.validRect[offsetProperty] + ); + + // The requestedRect also needs to be adjusted to account for the height difference. + if (layoutNode.node?.type === 'item') { + this.requestedRect[heightProperty] += + newLayoutInfo.rect[heightProperty] - layoutInfo.rect[heightProperty]; + } + + // Invalidate layout for this layout node and all parents + this.updateLayoutNode(key, layoutInfo, newLayoutInfo); + + let node = layoutInfo.parentKey != null ? collection.getItem(layoutInfo.parentKey) : null; + while (node) { + this.updateLayoutNode(node.key, layoutInfo, newLayoutInfo); + node = node.parentKey != null ? collection.getItem(node.parentKey) : null; + } + + return true; + } + + return false; + } + + private updateLayoutNode(key: Key, oldLayoutInfo: LayoutInfo, newLayoutInfo: LayoutInfo) { + let n = this.layoutNodes.get(key); + if (n) { + // Invalidate by intersecting the validRect of this node with the overall validRect. + n.validRect = n.validRect.intersection(this.validRect); + + // Replace layout info in LayoutNode + if (n.layoutInfo === oldLayoutInfo) { + n.layoutInfo = newLayoutInfo; + } + } + } + + getContentSize(): Size { + return this.contentSize; + } + + getDropTargetFromPoint( + x: number, + y: number, + isValidDropTarget: (target: DropTarget) => boolean + ): DropTarget | null { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error('Drag and drop is not supported for ListLayout with anchorTo="end".'); + } + + x += this.virtualizer!.visibleRect.x; + y += this.virtualizer!.visibleRect.y; + + // Find the closest item within on either side of the point using the gap width. + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2)); + let candidates = this.getVisibleLayoutInfos(searchRect); + let key: Key | null = null; + let minDistance = Infinity; + for (let candidate of candidates) { + // Ignore items outside the search rect, e.g. persisted keys. + if (!candidate.rect.intersects(searchRect)) { + continue; + } + + let yDist = Math.abs(candidate.rect.y - y); + let maxYDist = Math.abs(candidate.rect.maxY - y); + let dist = Math.min(yDist, maxYDist); + if (dist < minDistance) { + minDistance = dist; + key = candidate.key; + } + } + + if (key == null || this.virtualizer!.collection.size === 0) { + return {type: 'root'}; + } + + let layoutInfo = this.getLayoutInfo(key); + if (!layoutInfo) { + return null; + } + + let rect = layoutInfo.rect; + let target: DropTarget = { + type: 'item', + key: layoutInfo.key, + dropPosition: 'on' + }; + + // If dropping on the item isn't accepted, try the target before or after depending on the y position. + // Otherwise, if dropping on the item is accepted, still try the before/after positions if within 10px + // of the top or bottom of the item. + if (!isValidDropTarget(target)) { + if (y <= rect.y + rect.height / 2 && isValidDropTarget({...target, dropPosition: 'before'})) { + target.dropPosition = 'before'; + } else if (isValidDropTarget({...target, dropPosition: 'after'})) { + target.dropPosition = 'after'; + } + } else if (y <= rect.y + 10 && isValidDropTarget({...target, dropPosition: 'before'})) { + target.dropPosition = 'before'; + } else if (y >= rect.maxY - 10 && isValidDropTarget({...target, dropPosition: 'after'})) { + target.dropPosition = 'after'; + } + + return target; + } + + getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error('Drag and drop is not supported for ListLayout with anchorTo="end".'); + } + + let layoutInfo = this.getLayoutInfo(target.key)!; + let rect: Rect; + if (target.dropPosition === 'before') { + rect = + this.orientation === 'horizontal' + ? new Rect( + Math.max(0, layoutInfo.rect.x - this.dropIndicatorThickness / 2), + layoutInfo.rect.y, + this.dropIndicatorThickness, + layoutInfo.rect.height + ) + : new Rect( + layoutInfo.rect.x, + Math.max(0, layoutInfo.rect.y - this.dropIndicatorThickness / 2), + layoutInfo.rect.width, + this.dropIndicatorThickness + ); + } else if (target.dropPosition === 'after') { + // Render after last visible descendant of the drop target. + let targetNode = this.collection.getItem(target.key); + if (targetNode) { + let targetLevel = targetNode.level ?? 0; + let currentKey = this.collection.getKeyAfter(target.key); + + while (currentKey != null) { + let node = this.collection.getItem(currentKey); + if (!node || node.level <= targetLevel) { + break; + } + + layoutInfo = this.getLayoutInfo(currentKey) || layoutInfo; + currentKey = this.collection.getKeyAfter(currentKey); + } + } + rect = + this.orientation === 'horizontal' + ? new Rect( + layoutInfo.rect.maxX - this.dropIndicatorThickness / 2, + layoutInfo.rect.y, + this.dropIndicatorThickness, + layoutInfo.rect.height + ) + : new Rect( + layoutInfo.rect.x, + layoutInfo.rect.maxY - this.dropIndicatorThickness / 2, + layoutInfo.rect.width, + this.dropIndicatorThickness + ); + } else { + rect = layoutInfo.rect; + } + + return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect); + } + + private assertReversedCollectionSupported(nodes: Node[]) { + for (let node of nodes) { + if (node.type !== 'item' && node.type !== 'loader' && node.type !== 'separator') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + + if (node.parentKey != null || node.level > 0 || node.hasChildNodes) { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + } + } +} + +function toArray( + collection: Collection>, + predicate: (node: Node) => boolean +): Node[] { + const result: Node[] = []; + for (const node of collection) { + if (predicate(node)) { + result.push(node); + } + } + return result; +} diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 6ee4204bceb..9233598b35a 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -100,6 +100,7 @@ export interface PromptFieldProps { onRemoveAttachments?: (attachments: PromptFieldAttachment[]) => void; styles?: StyleString; variant?: 'balanced' | 'prominent' | 'subtle'; + brandColor?: string; hideDisclaimer?: boolean; } @@ -193,7 +194,8 @@ export const PromptField = forwardRef(function PromptField( onAddAttachments, onRemoveAttachments, hideDisclaimer, - variant = 'balanced' + variant = 'balanced', + brandColor } = props; let domRef = useDOMRef(ref); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); @@ -277,6 +279,7 @@ export const PromptField = forwardRef(function PromptField( {...dropProps} role="group" variant={variant} + brandColor={brandColor} isGenerating={isGenerating ?? false} isDropTarget={isDropTarget} styles={styles} @@ -404,7 +407,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { width: 'full' })({isFocused: isFocused || prompt.segments.length > 0})}> - + { variant: 'balanced' | 'prominent' | 'subtle'; isGenerating: boolean; isDropTarget: boolean; + brandColor?: string; styles?: StyleString; inputRef: React.RefObject; } export function PromptFieldContainer(props: PropFieldContainerProps) { - let {variant, isGenerating, isDropTarget, styles, inputRef, ...otherProps} = props; + let {variant, isGenerating, isDropTarget, styles, inputRef, brandColor, ...otherProps} = props; let [isFocused, setFocused] = useState(false); return ( @@ -272,6 +273,11 @@ export function PromptFieldContainer(props: PropFieldContainerProps) { data-state={isGenerating ? 'generating' : 'idle'} data-focused={isFocused || undefined} className={outerBorder} + style={{ + ...props.style, + // @ts-ignore + '--brand': brandColor + }} onFocus={e => { if (e.isTrusted) { setFocused(true); diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 37d84e4819b..5479c547983 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -14,7 +14,7 @@ import {AriaLabelingProps, DOMProps, DOMRef, GlobalDOMAttributes} from '@react-t import { baseColor, focusRing, - lightDark, + iconStyle, space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -56,18 +56,6 @@ export interface ResponseStatusProps extends Omit< RACDisclosureProps, 'className' | 'style' | 'render' | 'children' | keyof GlobalDOMAttributes > { - /** - * The size of the response status. - * - * @default 'M' - */ - size?: 'S' | 'M' | 'L' | 'XL'; - /** - * The amount of space between stacked response statuses. - * - * @default 'regular' - */ - density?: 'compact' | 'regular' | 'spacious'; /** * The current status of the response. * @@ -86,8 +74,6 @@ export interface ResponseStatusProps extends Omit< } const ResponseStatusContext = createContext<{ - size?: 'S' | 'M' | 'L' | 'XL'; - density?: 'compact' | 'regular' | 'spacious'; status: 'loading' | 'failed' | 'success'; hasPanelContent: boolean; registerPanel: (mounted: boolean) => void; @@ -111,7 +97,7 @@ export const ResponseStatus = forwardRef(function ResponseStatus( props: ResponseStatusProps, ref: DOMRef ) { - let {size = 'M', density = 'regular', status = 'loading', styles} = props; + let {status = 'loading', styles} = props; let domRef = useDOMRef(ref); let [hasPanelContent, setHasPanelContent] = useState(false); let registerPanel = useCallback((mounted: boolean) => setHasPanelContent(mounted), []); @@ -123,8 +109,7 @@ export const ResponseStatus = forwardRef(function ResponseStatus( } return ( - + - ) : isInteractive ? ( - - - - ) : null} - {props.children} - {!isLoading && ( + ) : ( @@ -335,6 +248,12 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( )} + {props.children} + {isInteractive ? ( + + + + ) : null} ); @@ -342,7 +261,7 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( + ); +} + +export interface ExecutionTraceItemProps extends DOMProps, AriaLabelingProps { + /** + * The label describing the step. + */ + children: ReactNode; + detail?: ReactNode; + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + /** + * An icon shown at the leading edge of the row. If omitted, a checkmark is rendered by default. + */ + icon?: ReactNode; + /** Allows detail content to render but prevents the row from being collapsible. */ + isAlwaysOpen?: boolean; + /** + * Additional detail revealed when the step is expanded, such as tool call input or output. + * If omitted, the row is static and cannot be expanded. + */ + + styles?: StyleString; +} + +const executionTraceItemStyles = style({ + display: 'flex', + font: 'body', + gap: 4, + '--divider-display': { + type: 'display', + value: { + default: 'block', + ':last-child': 'none' + } + } +}); + +const executionTraceItemIconContainerStyles = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + flexShrink: 0 +}); + +const executionTraceItemDividerStyles = style({ + width: 1, + flexGrow: 1, + marginY: 2, + backgroundColor: 'gray-500', + display: 'var(--divider-display, flex)' +}); + +const executionTraceItemBaseStyles = { + paddingBottom: 12, + paddingStart: 8 +} as const; + +const executionTraceWithoutDisclosureStyles = style({ + ...executionTraceItemBaseStyles, + display: 'flex', + flexDirection: 'column', + minHeight: 24 +}); + +const executionTraceDetailPanelStyles = style(executionTraceItemBaseStyles); + +/** + * An ExecutionTraceItem represents a single step within an ExecutionTrace, such as + * a tool call or search. When a `detail` is provided, the row can be expanded to reveal it. + */ +export const ExecutionTraceItem = forwardRef(function ExecutionTraceItem( + props: ExecutionTraceItemProps, + ref: DOMRef +) { + let { + isAlwaysOpen, + detail, + icon =