← All proposals

Agent Step

One row: an optional icon (status or custom), a label, quiet secondary text and optional expandable detail.

Net-new componentOpen in Storybook

proposals/AgentStep/AgentStep.tsx154 lines · View on GitHub
import { ComponentPropsWithoutRef, ReactNode } from 'react';

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

import './agent-step.css';

export type AgentStepStatus = 'active' | 'done' | 'error' | 'pending';

export interface AgentStepProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'children'
> {
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Optional expandable content, such as a tool call's input and output. A
	 * step with `detail` becomes a toggle.
	 */
	detail?: ReactNode;
	/**
	 * Quiet text for secondary rows such as a search query or "+7 more".
	 */
	isQuiet?: boolean;
	/**
	 * Replaces the status icon, for example a wrench for a tool call. While the
	 * step is `active` the spinner still shows, and the status color and
	 * screen reader text still follow `status`.
	 */
	iconName?: IconProps['iconName'];
	/**
	 * What the agent did or is doing.
	 */
	label: ReactNode;
	/**
	 * Object with translatable strings, read by screen readers with the status.
	 */
	labelStrings?: Record<AgentStepStatus, string>;
	/**
	 * Quiet supporting text after the label. Wrap file names, commands and
	 * other code in `<code>`.
	 */
	secondaryText?: ReactNode;
	/**
	 * Where the step is at. Without it (and without `iconName`) the step has no
	 * icon.
	 */
	status?: AgentStepStatus;
}

// 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(' ');

const defaultLabelStrings: Record<AgentStepStatus, string> = {
	active: 'In progress',
	done: 'Done',
	error: 'Failed',
	pending: 'Pending',
};

const defaultIcons: Record<AgentStepStatus, IconProps['iconName']> = {
	active: 'circle',
	done: 'check',
	error: 'diamondExclamation',
	pending: 'circle',
};

/**
 * AgentStep UI component. One thing an agent did or is doing: a status icon, a
 * label, quiet secondary text and optional expandable detail. Use it alone inline or
 * inside `ChainOfThought`.
 */
export const AgentStep = ({
	className,
	detail,
	iconName,
	isQuiet = false,
	label,
	labelStrings = defaultLabelStrings,
	secondaryText,
	status,
	...props
}: AgentStepProps) => {
	const baseClass = 'pds-agent-step';
	const hasIcon = status !== undefined || iconName !== undefined;

	const statusIcon = () => {
		if (status === 'active') {
			return (
				<Spinner
					className={`${baseClass}__status`}
					color='var(--agent-step-status-color)'
					label={labelStrings.active}
					size='s'
				/>
			);
		}
		return (
			<>
				<Icon
					className={`${baseClass}__status`}
					iconName={iconName ?? defaultIcons[status ?? 'pending']}
					size='xs'
				/>
				{status && (
					<span className='visually-hidden'>{labelStrings[status]}</span>
				)}
			</>
		);
	};

	const row = (
		<>
			{hasIcon && statusIcon()}
			<span className={`${baseClass}__label`}>{label}</span>
			{secondaryText && (
				<span className={`${baseClass}__secondary-text`}>{secondaryText}</span>
			)}
		</>
	);

	return (
		<div
			className={mergeClasses([
				baseClass,
				status ? `${baseClass}--${status}` : null,
				isQuiet ? `${baseClass}--quiet` : null,
				className,
			])}
			{...props}
		>
			{detail ? (
				<details>
					<summary className={`${baseClass}__row`}>
						{row}
						<Icon
							className={`${baseClass}__toggle`}
							iconName='angleRight'
							size='xs'
						/>
					</summary>
					<div className={`${baseClass}__detail`}>{detail}</div>
				</details>
			) : (
				<div className={`${baseClass}__row`}>{row}</div>
			)}
		</div>
	);
};