// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ChatToolCalls.tsx * @input Uses React, StyleX, semantic icon registry, theme tokens * @output Exports ChatToolCalls component * @position Chat component — displays tool/function call invocations * * Single component that handles both individual and grouped tool calls. * Accepts an array of call data matching the shape LLM APIs already return. * Single call renders inline; multiple calls get a collapsible summary. * * Design rationale: every LLM API (Vercel AI SDK, Anthropic, OpenAI) * returns tool calls as an array on the message. One component with a * data prop matches that shape directly — no nested compound components * for builders to wire up. */ import React, { useState, useCallback, useId, type ReactNode } from 'react'; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, typeScaleVars, typographyVars, fontWeightVars, radiusVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { getKey, mergeProps } from "../utils/index.js"; import { Badge } from "../Badge/index.js"; import { Icon, type IconName } from "../Icon/index.js"; import { Spinner } from "../Spinner/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; // ============================================================================= // Types // ============================================================================= export type ChatToolCallStatus = 'pending' | 'running' | 'complete' | 'error'; export interface ChatToolCallItem { /** Tool/function name. */ name: string; /** Current execution status. @default 'complete' */ status?: ChatToolCallStatus; /** The target of the action (e.g. "Button.tsx", "yarn test", "CSS anchor positioning"). */ target?: string; /** Duration string (e.g. "1.2s", "340ms"). Shown when complete. */ duration?: string; /** Sandbox/node name (e.g. "navi", "xds"). Shown as a pill badge. */ node?: string; /** Lines/characters added. Rendered in green (e.g. "+12"). */ additions?: number; /** Lines/characters removed. Rendered in red (e.g. "-3"). */ deletions?: number; /** Additional info rendered after the label. Free-form ReactNode. */ stats?: ReactNode; /** * Error message when status is 'error'. Rendered as visually hidden text in * the row (so screen readers and keyboard users perceive it) and echoed in a * hover tooltip on the status icon. */ errorMessage?: string; /** Unique key for React list rendering. Derived from stable metadata if omitted. */ key?: string; /** Arbitrary data passed through to renderDetail. Store tool args, result, etc. */ data?: unknown; /** Inline detail content shown when the row is expanded (e.g. code diff, command output). */ resultDetail?: ReactNode; } export interface ChatToolCallsProps extends BaseProps { ref?: React.Ref; /** Array of tool call data. */ calls: ChatToolCallItem[]; /** Custom summary label for groups. Auto-generated from count if omitted. */ label?: string; /** Whether the group is expanded. Uncontrolled by default. */ isExpanded?: boolean; /** Default expanded state. @default true for ≤3 calls, false for >3. */ defaultIsExpanded?: boolean; /** Callback when expanded state changes. */ onExpandedChange?: (isExpanded: boolean) => void; } // ============================================================================= // ============================================================================= // Styles // ============================================================================= const styles = { root: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", keoZOQ: "xtbrsbv", $$css: true as const }, chevronTransition: { kmkexE: "x1ob6yzd x9kvfbb", $$css: true as const }, chevronExpanded: { k3aq6I: "x19jd1h0", $$css: true as const }, statusIcon: { kVAEAm: "x1n2onr6", k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", kzqmXN: "x1kky2od", kZKoxP: "xlup9mm", kaIpWk: "xjspbzw", $$css: true as const }, nodePill: { kmuXW: "x2lah0s", $$css: true as const }, colorPending: { kMwMTN: "xnbbluu", $$css: true as const }, colorRunning: { kMwMTN: "xqwr325", $$css: true as const }, colorComplete: { kMwMTN: "xtjic6", $$css: true as const }, colorError: { kMwMTN: "xjt36v0", $$css: true as const } }; // ============================================================================= // Status Icons // ============================================================================= const STATUS_ICON_NAMES: Record = { pending: 'clock', running: null, complete: 'success', error: 'error' }; const STATUS_STYLES: Record[string]> = { pending: styles.colorPending, running: styles.colorRunning, complete: styles.colorComplete, error: styles.colorError }; function getStatusAnnouncement(t: ReturnType, status: ChatToolCallStatus, errorMessage?: string): string { if (status === 'error' && errorMessage != null) { return t('@astryx.chatToolCalls.error', { message: errorMessage }); } switch (status) { case 'pending': return t('@astryx.chatToolCalls.status.pending'); case 'running': return t('@astryx.chatToolCalls.status.running'); case 'complete': return t('@astryx.chatToolCalls.status.complete'); case 'error': return t('@astryx.chatToolCalls.status.error'); } } function getToolCallKey(call: ChatToolCallItem): string { return getKey(call.key, () => [call.name, call.status ?? 'complete', call.target ?? '', call.node ?? '', call.duration ?? '', call.additions?.toString() ?? '', call.deletions?.toString() ?? '', call.errorMessage ?? ''].join('\u001F')); } // ============================================================================= // Internal: single call row // ============================================================================= function CallRow({ call }: { call: ChatToolCallItem; }) { const t = useTranslator(); const status = call.status ?? 'complete'; const hasDetail = call.resultDetail != null; const [isDetailOpen, setIsDetailOpen] = useState(false); const detailId = useId(); const statusAnnouncement = getStatusAnnouncement(t, status, call.errorMessage); const toggleDetail = hasDetail ? () => setIsDetailOpen(prev => !prev) : undefined; const row =
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDetail?.(); } } : undefined} {...{ 0: { className: "x78zum5 x6s0dn4 x1s4dlld xjwf9q1 x13f7esw" }, 1: { className: "x78zum5 x6s0dn4 x1s4dlld xjwf9q1 x13f7esw x1ypdohk x16khyan xh6dtrn x7a5moj xtedp8i xoevpu5" } }[Number(!!hasDetail) << 0]}> {status === 'running' ? {call.name} {call.node != null && } {call.target != null && {call.target}} {(call.additions != null || call.deletions != null || call.stats != null) && {call.additions != null && +{call.additions} } {call.deletions != null && -{call.deletions} } {call.stats} } {call.duration != null && status === 'complete' && {call.duration}} {hasDetail && }
; if (!hasDetail) { return row; } return
{row} {isDetailOpen &&
{call.resultDetail}
}
; } // ============================================================================= // Component // ============================================================================= /** * Displays tool/function call invocations from an LLM response. * * Accepts a `calls` array matching the shape LLM APIs return. Single call * renders inline without group chrome. Multiple calls get a collapsible * summary header. * * @example * ``` * ({ * name: tc.toolName, * status: tc.state, * duration: tc.duration, * }))} * renderDetail={(call) => ( * * )} * /> * ``` */ export function ChatToolCalls(props: ChatToolCallsProps) { const t = useTranslator(); const { calls, label: _customLabel, isExpanded: controlledExpanded, defaultIsExpanded, onExpandedChange, xstyle, className, style, ref, ...rest } = props; const autoDefault = defaultIsExpanded ?? false; const [internalExpanded, setInternalExpanded] = useState(autoDefault); const contentId = useId(); const isControlled = controlledExpanded !== undefined; const isExpanded = isControlled ? controlledExpanded : internalExpanded; const toggle = useCallback(() => { const next = !isExpanded; if (!isControlled) { setInternalExpanded(next); } onExpandedChange?.(next); }, [isExpanded, isControlled, onExpandedChange]); if (calls.length === 0) { return null; } // Single call: render inline, no group chrome if (calls.length === 1) { return
; } // Multiple calls: latest call at surface with chevron to expand all const latestCall = calls[calls.length - 1]; const latestStatus = latestCall.status ?? 'complete'; const latestStatusAnnouncement = getStatusAnnouncement(t, latestStatus, latestCall.errorMessage); return
{/* Header: collapsed shows latest call + count, expanded shows summary label */}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }} className="x78zum5 x6s0dn4 x1s4dlld xjwf9q1 x13f7esw x1ypdohk x16khyan"> {isExpanded ? <> {t('@astryx.chatToolCalls.groupLabel', { count: calls.length })} : <> {latestStatus === 'running' ? {latestCall.name} {latestCall.target != null && {latestCall.target} } } {!isExpanded && <> {calls.length} }
{/* Expanded: all calls with full metadata */}
{calls.map(call => )}
; } ChatToolCalls.displayName = 'ChatToolCalls';