import { memo, useCallback, useEffect, useRef } from 'react' import { useI18n } from '../../../i18n' import { stripStudioReferenceImages } from '../../reference-images' import { debugStudioMessages } from '../../agent-response/debug' import type { StudioMessage } from '../../protocol/studio-agent-types' import { StudioMarkdown } from '../StudioMarkdown' import { selectRowView } from './selectors' import type { StudioCommandPanelStore } from './store' import { useCommandStoreSelector } from './use-command-store-selector' interface StudioCommandMessageRowProps { messageId: string store: StudioCommandPanelStore variant?: 'default' | 't-layout-bottom' | 'pure-minimal-bottom' } const animatedMessageIds = new Set() export const StudioCommandMessageRow = memo(function StudioCommandMessageRow({ messageId, store, variant = 'default', }: StudioCommandMessageRowProps) { const selector = useCallback( (snapshot: ReturnType) => selectRowView(snapshot, messageId), [messageId], ) const rowView = useCommandStoreSelector(store, selector, areRowViewsEqual) const renderCountRef = useRef(0) const prevRowViewRef = useRef(rowView) const shouldAnimateEnter = !animatedMessageIds.has(messageId) renderCountRef.current += 1 useEffect(() => { animatedMessageIds.add(messageId) debugStudioMessages('command-row-mounted', { messageId, role: rowView.message?.role ?? 'missing', shouldAnimateEnter, }) return () => { debugStudioMessages('command-row-unmounted', { messageId, }) } }, [messageId, rowView.message?.role, shouldAnimateEnter]) useEffect(() => { const previous = prevRowViewRef.current debugStudioMessages('command-row-rendered', { messageId, renderCount: renderCountRef.current, role: rowView.message?.role ?? 'missing', changed: describeRowViewChange(previous, rowView), isStreamingTarget: rowView.isStreamingTarget, showCaret: rowView.showCaret, streamedLength: rowView.streamedText.length, }) prevRowViewRef.current = rowView }, [messageId, rowView]) if (!rowView.message) { return null } if (rowView.message.role === 'user') { return ( ) } return ( ) }) const UserMessageItem = memo(function UserMessageItem({ message, shouldAnimateEnter, minimal, }: { message: Extract shouldAnimateEnter: boolean minimal: boolean }) { const { t } = useI18n() if (minimal) { return (
{'>'}
) } return (
{t('studio.inputUser')}
) }) const AssistantMessageItem = memo(function AssistantMessageItem({ message, shouldAnimateEnter, isStreamingTarget, streamedText, showCaret, minimal, }: { message: Extract shouldAnimateEnter: boolean isStreamingTarget: boolean streamedText: string showCaret: boolean minimal: boolean }) { const { t } = useI18n() const textParts = message.parts.filter((part) => part.type === 'text' || part.type === 'reasoning') const toolParts = message.parts.filter((part) => part.type === 'tool') const hasStreamedText = streamedText.length > 0 const hasRenderableText = textParts.some((part) => part.text.trim()) if (minimal) { return (
{'•'}
{isStreamingTarget && hasStreamedText ? ( ) : isStreamingTarget && !hasRenderableText ? (
{t('studio.thinking')}
) : textParts.map((part, i) => { const text = part.text.trim() if (!text) return null return ( ) })} {!isStreamingTarget && !hasRenderableText && (
{t('studio.noResponseOutput')}
)} {toolParts.length > 0 && (
{toolParts.map((part, i) => { const status = part.state.status === 'error' ? '!' : part.state.status === 'completed' ? '->' : '...' const args = 'input' in part.state ? truncateArgs(part.state.input) : '' return (
{status} {part.tool} ({args})
{part.state.status === 'error' && (
{part.state.error}
)}
) })}
)}
) } return (
{t('studio.outputAgent')}
{isStreamingTarget && hasStreamedText ? ( ) : isStreamingTarget && !hasRenderableText ? (
{t('studio.thinking')}
) : textParts.map((part, i) => { const text = part.text.trim() if (!text) return null return ( ) })} {!isStreamingTarget && !hasRenderableText && (
{t('studio.noResponseOutput')}
)} {toolParts.length > 0 && (
{toolParts.map((part, i) => { const status = part.state.status === 'error' ? '!' : part.state.status === 'completed' ? '->' : '...' const args = 'input' in part.state ? truncateArgs(part.state.input) : '' return (
{status} {part.tool} ({args})
{part.state.status === 'error' && (
{part.state.error}
)}
) })}
)}
) }) function neutralToolTone(status: string) { switch (status) { case 'error': return 'text-rose-500/70' case 'completed': return 'text-text-primary/40' default: return 'text-amber-500/70' } } function truncateArgs(input?: Record) { if (!input) return '' const str = JSON.stringify(input) return str.length > 60 ? `${str.slice(0, 57)}...` : str } function areRowViewsEqual( left: ReturnType, right: ReturnType, ) { return left.message === right.message && left.isStreamingTarget === right.isStreamingTarget && left.streamedText === right.streamedText && left.showCaret === right.showCaret } function describeRowViewChange( previous: ReturnType, next: ReturnType, ) { const reasons: string[] = [] if (previous.message !== next.message) { reasons.push('message-ref') } if (previous.isStreamingTarget !== next.isStreamingTarget) { reasons.push('stream-target') } if (previous.streamedText !== next.streamedText) { reasons.push('stream-text') } if (previous.showCaret !== next.showCaret) { reasons.push('caret') } return reasons.length > 0 ? reasons : ['no-diff'] }