248 lines
7.1 KiB
TypeScript
248 lines
7.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { AlertTriangle, Brain, CheckCircle2, ChevronDown, LoaderCircle, Wrench } from 'lucide-react'
|
|
|
|
import { sanitizeAssistantText } from '../lib/chat'
|
|
import type { ToolNestedEvent, ToolSegment } from '../types'
|
|
import { AnimatedDisclosure } from './AnimatedDisclosure'
|
|
import { StreamingMarkdown } from './StreamingMarkdown'
|
|
|
|
interface ToolCallCardProps {
|
|
activity: ToolSegment
|
|
}
|
|
|
|
function statusLabel(status: ToolSegment['status']): string {
|
|
if (status === 'running') {
|
|
return 'Running'
|
|
}
|
|
if (status === 'completed') {
|
|
return 'Completed'
|
|
}
|
|
if (status === 'error') {
|
|
return 'Failed'
|
|
}
|
|
return 'Queued'
|
|
}
|
|
|
|
function StatusIcon({ status }: { status: ToolSegment['status'] }) {
|
|
if (status === 'running' || status === 'pending') {
|
|
return <LoaderCircle size={14} className="spin" />
|
|
}
|
|
if (status === 'completed') {
|
|
return <CheckCircle2 size={14} />
|
|
}
|
|
return <AlertTriangle size={14} />
|
|
}
|
|
|
|
function nestedStatusLabel(status: ToolNestedEvent['status']): string {
|
|
if (status === 'running') {
|
|
return 'Running'
|
|
}
|
|
if (status === 'completed') {
|
|
return 'Done'
|
|
}
|
|
if (status === 'error') {
|
|
return 'Failed'
|
|
}
|
|
return 'Info'
|
|
}
|
|
|
|
function nestedEventVariant(event: ToolNestedEvent): string {
|
|
const label = event.detailLabel.trim().toLowerCase()
|
|
|
|
if (label === 'reasoning') {
|
|
return 'reasoning'
|
|
}
|
|
|
|
if (label === 'response') {
|
|
return 'response'
|
|
}
|
|
|
|
if (label === 'error') {
|
|
return 'error'
|
|
}
|
|
|
|
if (label === 'result') {
|
|
return 'result'
|
|
}
|
|
|
|
if (label === 'progress') {
|
|
return 'progress'
|
|
}
|
|
|
|
return 'detail'
|
|
}
|
|
|
|
function isNarrativeNestedEvent(event: ToolNestedEvent): boolean {
|
|
const variant = nestedEventVariant(event)
|
|
return variant === 'response' || variant === 'reasoning'
|
|
}
|
|
|
|
function NestedToolCard({ event }: { event: ToolNestedEvent }) {
|
|
const variant = nestedEventVariant(event)
|
|
const isRunning = event.status === 'running'
|
|
const [isOpen, setIsOpen] = useState(isRunning)
|
|
const previousStatusRef = useRef(event.status)
|
|
|
|
useEffect(() => {
|
|
if (isRunning) {
|
|
setIsOpen(true)
|
|
} else if (previousStatusRef.current === 'running' && event.status !== 'running') {
|
|
setIsOpen(false)
|
|
}
|
|
|
|
previousStatusRef.current = event.status
|
|
}, [event.status, isRunning])
|
|
|
|
return (
|
|
<AnimatedDisclosure
|
|
bodyClassName="tool-nested-body"
|
|
bodyWrapClassName="tool-nested-body-wrap"
|
|
className={`tool-nested-item is-${variant}`}
|
|
onToggle={setIsOpen}
|
|
open={isOpen}
|
|
summary={
|
|
<div className="tool-nested-item-topline">
|
|
<strong>{event.title}</strong>
|
|
<div className="tool-nested-item-actions">
|
|
<span className={`tool-nested-status is-${event.status}`}>{nestedStatusLabel(event.status)}</span>
|
|
<ChevronDown className="tool-nested-chevron" size={14} />
|
|
</div>
|
|
</div>
|
|
}
|
|
summaryClassName="tool-nested-summary"
|
|
>
|
|
{event.argumentsPreview ? (
|
|
<div className="tool-nested-block is-arguments">
|
|
<span className="tool-card-label">Arguments</span>
|
|
<pre>{event.argumentsPreview}</pre>
|
|
</div>
|
|
) : null}
|
|
{event.detailPreview ? (
|
|
<div className={`tool-nested-block is-${variant}`}>
|
|
<span className="tool-card-label">{event.detailLabel}</span>
|
|
<pre>{event.detailPreview}</pre>
|
|
</div>
|
|
) : null}
|
|
</AnimatedDisclosure>
|
|
)
|
|
}
|
|
|
|
function InlineReasoningCard({ title, text, autoOpen }: { title: string; text: string; autoOpen: boolean }) {
|
|
const [isOpen, setIsOpen] = useState(autoOpen)
|
|
|
|
useEffect(() => {
|
|
if (autoOpen) {
|
|
setIsOpen(true)
|
|
}
|
|
}, [autoOpen])
|
|
|
|
return (
|
|
<AnimatedDisclosure
|
|
bodyClassName="reasoning-card-body"
|
|
bodyWrapClassName="reasoning-card-body-wrap"
|
|
className="reasoning-card tool-inline-reasoning"
|
|
onToggle={setIsOpen}
|
|
open={isOpen}
|
|
summary={
|
|
<div className="reasoning-card-title">
|
|
<Brain size={14} />
|
|
<strong>{title}</strong>
|
|
</div>
|
|
}
|
|
summaryClassName="reasoning-card-summary"
|
|
>
|
|
<StreamingMarkdown pulseOnMount={autoOpen} text={text} />
|
|
</AnimatedDisclosure>
|
|
)
|
|
}
|
|
|
|
export function ToolCallCard({ activity }: ToolCallCardProps) {
|
|
const isRunning = activity.status === 'pending' || activity.status === 'running'
|
|
const [isOpen, setIsOpen] = useState(isRunning)
|
|
|
|
useEffect(() => {
|
|
if (isRunning) {
|
|
setIsOpen(true)
|
|
}
|
|
}, [isRunning])
|
|
|
|
return (
|
|
<AnimatedDisclosure
|
|
bodyClassName="tool-card-body"
|
|
bodyWrapClassName="tool-card-body-wrap"
|
|
className="tool-card"
|
|
onToggle={setIsOpen}
|
|
open={isOpen}
|
|
summary={
|
|
<div className="tool-card-title-row">
|
|
<div className={`tool-status-pill is-${activity.status}`}>
|
|
<StatusIcon status={activity.status} />
|
|
<span>{statusLabel(activity.status)}</span>
|
|
</div>
|
|
<div className="tool-card-title">
|
|
<Wrench size={14} />
|
|
<strong>{activity.toolName}</strong>
|
|
</div>
|
|
<ChevronDown size={16} className="tool-card-chevron" />
|
|
</div>
|
|
}
|
|
summaryClassName="tool-card-summary"
|
|
>
|
|
{activity.argumentsPreview ? (
|
|
<div className="tool-card-section">
|
|
<span className="tool-card-label">Arguments</span>
|
|
<pre>{activity.argumentsPreview}</pre>
|
|
</div>
|
|
) : null}
|
|
|
|
{activity.nestedEvents.length ? (
|
|
<div className="tool-card-section">
|
|
<span className="tool-card-label">Subagent Trace</span>
|
|
<div className="tool-card-middle-sequence">
|
|
{activity.nestedEvents.map((event) => {
|
|
const variant = nestedEventVariant(event)
|
|
if (isNarrativeNestedEvent(event)) {
|
|
const rawText =
|
|
variant === 'response'
|
|
? sanitizeAssistantText(event.detailPreview)
|
|
: event.detailPreview.trim()
|
|
|
|
if (!rawText) {
|
|
return null
|
|
}
|
|
|
|
if (variant === 'reasoning') {
|
|
return (
|
|
<InlineReasoningCard
|
|
autoOpen={isRunning}
|
|
key={event.id}
|
|
text={rawText}
|
|
title={event.title}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="bubble bubble-assistant bubble-tool-inline" key={event.id}>
|
|
<span className="bubble-role">{event.title}</span>
|
|
<StreamingMarkdown pulseOnMount={isRunning} text={rawText} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return <NestedToolCard event={event} key={event.id} />
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{activity.detailPreview ? (
|
|
<div className="tool-card-section">
|
|
<span className="tool-card-label">{activity.status === 'error' ? 'Error' : 'Result'}</span>
|
|
<pre>{activity.detailPreview}</pre>
|
|
</div>
|
|
) : null}
|
|
</AnimatedDisclosure>
|
|
)
|
|
}
|