← All proposals

Thinking State

A small inline status: the agent icon (Zappy by default), a label ("Thinking..."), and the elapsed time in the code font ("17.1s").

Net-new componentOpen in Storybook

proposals/ThinkingState/ThinkingState.tsx98 lines · View on GitHub
import { ComponentPropsWithoutRef } from 'react';

import { Icon, IconProps } from '../Icon/Icon';

import './thinking-state.css';

export type ThinkingStateSize = 'xs' | 's' | 'm';

export type ThinkingStateState = 'active' | 'completed' | 'stopped';

export interface ThinkingStateProps extends ComponentPropsWithoutRef<'div'> {
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Which icon to render before the label.
	 */
	iconName?: IconProps['iconName'];
	/**
	 * What the agent is doing (for example "Thinking..."). Announced to screen
	 * readers.
	 */
	label: string;
	/**
	 * Size. Sets the text size, and the icon size follows it (the icon is the
	 * same height as the text); the time is one step smaller and the gap grows
	 * with the size.
	 */
	size?: ThinkingStateSize;
	/**
	 * Where the run is. `active` sweeps a shimmer across the label (a highlight
	 * moving through the text itself) and pulses the icon. `completed` shows
	 * the same text without motion. `stopped` is for a run the user cut off:
	 * no motion, and the icon and label are muted to show it did not finish.
	 */
	state?: ThinkingStateState;
	/**
	 * Should the elapsed time be shown? It only renders when `time` is also set.
	 */
	showTimer?: boolean;
	/**
	 * Elapsed time, formatted by the consumer (for example "17s"). Pair it with
	 * a label that ends before the number ("Thought for"). Hidden from screen
	 * readers while the state is `active`, so a ticking value isn't announced;
	 * once the state is `completed` or `stopped` it is the final value and is
	 * read.
	 */
	time?: string;
}

// Stand-in for the v2 `mergeClasses` util, which the published package does
// not export. The port uses the real one.
const mergeClasses = (classes: Array<string | null | undefined>) =>
	classes.filter(Boolean).join(' ');

/**
 * ThinkingState UI component. Shows that an agent is working: an icon, what it
 * is doing, and how long it has been at it.
 */
export const ThinkingState = ({
	className,
	iconName = 'zappy',
	label,
	showTimer = false,
	size = 's',
	state = 'active',
	time,
	...props
}: ThinkingStateProps) => {
	const baseClass = 'pds-thinking-state';

	return (
		<div
			className={mergeClasses([
				baseClass,
				`pds-copy-${size}`,
				size !== 's' ? `${baseClass}--${size}` : null,
				`${baseClass}--${state}`,
				className,
			])}
			role='status'
			{...props}
		>
			<Icon className={`${baseClass}__icon`} iconName={iconName} size={size} />
			<span className={`${baseClass}__label`}>{label}</span>
			{showTimer && time && (
				<span
					aria-hidden={state === 'active' ? 'true' : undefined}
					className={`${baseClass}__time`}
				>
					{time}
				</span>
			)}
		</div>
	);
};