← All proposals

Chain of Thought

A collapsible list.

Net-new componentOpen in Storybook

proposals/ChainOfThought/ChainOfThought.tsx154 lines · View on GitHub
import {
	Children,
	ComponentPropsWithoutRef,
	ReactNode,
	useEffect,
	useRef,
	useState,
} from 'react';

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

import './chain-of-thought.css';

export type ChainOfThoughtSize = 'm' | 's';

export type ChainOfThoughtVariant = 'coding' | 'reasoning' | 'search' | 'steps';

export interface ChainOfThoughtProps extends ComponentPropsWithoutRef<'div'> {
	/**
	 * The body. For `reasoning`, the text (paragraphs). For the other variants,
	 * `AgentStep` components, each wrapped in a list item.
	 */
	children?: ReactNode;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Open while `isActive` is true, and close again when it turns false. Once
	 * the user opens or closes the list themselves, it stays as they left it.
	 */
	expandWhenActive?: boolean;
	/**
	 * Which icon to render before the summary label.
	 */
	iconName?: IconProps['iconName'];
	/**
	 * Is the agent still working? Shimmers the summary label and pulses its
	 * icon. Leave `false` for a finished record.
	 */
	isActive?: boolean;
	/**
	 * Is the list open?
	 */
	isOpen?: boolean;
	/**
	 * The always-visible line that toggles the list (for example "Thought for 4
	 * seconds"). Rendered as a `ThinkingState`.
	 */
	label: string;
	/**
	 * Size of the whole block. `s` is a 14px summary over 12px rows; `m` is a
	 * 16px summary over 14px rows.
	 */
	size?: ChainOfThoughtSize;
	/**
	 * Should the elapsed time be shown? Only renders when `time` is also set.
	 */
	showTimer?: boolean;
	/**
	 * Elapsed time, formatted by the consumer (for example "17.1s").
	 */
	time?: string;
	/**
	 * What the list shows. `steps` is plain progress with status icons,
	 * `reasoning` is the model's text, `search` is a query and its results,
	 * `coding` is tool calls such as read, edit and run.
	 */
	variant?: ChainOfThoughtVariant;
}

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

/**
 * ChainOfThought UI component. A collapsible record of what an agent did: its
 * reasoning, a web search, tool calls or plain progress.
 */
export const ChainOfThought = ({
	children,
	className,
	expandWhenActive = false,
	iconName,
	isActive = false,
	isOpen = false,
	label,
	showTimer = false,
	size = 's',
	time,
	variant = 'steps',
	...props
}: ChainOfThoughtProps) => {
	const [isExpanded, setIsExpanded] = useState(
		isOpen || (expandWhenActive && isActive),
	);
	const hasToggled = useRef(false);

	useEffect(() => {
		if (expandWhenActive && !hasToggled.current) setIsExpanded(isActive);
	}, [expandWhenActive, isActive]);
	const baseClass = 'pds-chain-of-thought';

	return (
		<div
			className={mergeClasses([
				baseClass,
				size !== 's' ? `${baseClass}--${size}` : null,
				variant !== 'steps' ? `${baseClass}--${variant}` : null,
				className,
			])}
			{...props}
		>
			<details open={isExpanded}>
				<summary
					onClick={(e) => {
						e.preventDefault();
						hasToggled.current = true;
						setIsExpanded((prev) => !prev);
					}}
				>
					<ThinkingState
						iconName={iconName}
						label={label}
						showTimer={showTimer}
						size={size}
						state={isActive ? 'active' : 'completed'}
						time={time}
					/>
					<Icon
						className={mergeClasses([
							`${baseClass}__icon`,
							isExpanded ? `${baseClass}__icon--open` : null,
						])}
						iconName='angleDown'
						size='s'
					/>
				</summary>
				{variant === 'reasoning' ? (
					<div className={`${baseClass}__list`}>{children}</div>
				) : (
					<ol className={`${baseClass}__list`}>
						{Children.map(children, (child) => (
							<li>{child}</li>
						))}
					</ol>
				)}
			</details>
		</div>
	);
};