import { memo, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { computeDiffStats } from '../../../utils/diffStats' import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons' import type { ToolPart } from '../../../types/message' import { useDelayedRender } from '../../../hooks' import { useNow } from '../../../hooks/useNow' import { serverStore } from '../../../store/serverStore' import { useTheme } from '../../../hooks/useTheme' import { formatToolName, formatDuration } from '../../../utils/formatUtils' import { useInlineToolRequests, findPermissionRequestForTool, findQuestionRequestForTool, } from '../../chat/InlineToolRequestContext' import { InlinePermission } from '../../chat/InlinePermission' import { InlineQuestion } from '../../chat/InlineQuestion' import { getToolIcon, extractToolData, getToolConfig, DefaultRenderer, TodoRenderer, TaskRenderer, hasTodos, } from '../tools' // ============================================ // ToolPartView - 单个工具调用 // ============================================ interface ToolPartViewProps { part: ToolPart isFirst?: boolean isLast?: boolean /** Compact layout: icon inline with text (14px column), no timeline connectors. * Used for single-tool groups to align with ReasoningPartView. */ compact?: boolean /** Descriptive steps mode: no icon/timeline, flat rows aligned with step summary. */ descriptive?: boolean /** Parent assistant message is still streaming. */ isStreaming?: boolean } export const ToolPartView = memo(function ToolPartView({ part, isFirst = false, isLast = false, compact = false, descriptive = false, isStreaming = false, }: ToolPartViewProps) { const { t } = useTranslation('message') const { state, tool: toolName } = part const status = state?.status || 'pending' const title = state?.title || getInputDescription(part) || '' const isActive = status === 'running' || status === 'pending' const isError = status === 'error' const now = useNow(250, isActive) const startTime = state?.time?.start const calibratedNow = isActive ? serverStore.getActiveCalibratedNow() : undefined const endTime = state?.time?.end ?? (isActive ? (calibratedNow ?? now) : undefined) const rawDuration = startTime !== undefined && endTime !== undefined ? endTime - startTime : undefined const duration = rawDuration !== undefined && isActive ? Math.max(0, rawDuration) : rawDuration const { inlineToolRequests, immersiveMode, compactInlinePermission } = useTheme() const { pendingPermissions, pendingQuestions, onPermissionReply, onQuestionReply, onQuestionReject, isReplying } = useInlineToolRequests() const childSessionId = getTaskChildSessionId(part) const permissionRequest = inlineToolRequests ? findPermissionRequestForTool(pendingPermissions, part.callID, childSessionId) : undefined const questionRequest = inlineToolRequests ? findQuestionRequestForTool(pendingQuestions, part.callID, childSessionId) : undefined const toolDone = status === 'completed' || status === 'error' // ── 延迟卸载 edit/write 权限组件 ── // 用户授权后 permissionRequest 会立即消失,但工具结果可能还没到, // 为了避免 "权限消失→空白→结果出现" 的跳动,缓存最后一次权限请求, // 在工具完成之前继续渲染(以 resolved 状态) const [cachedPermissionRequest, setCachedPermissionRequest] = useState(permissionRequest) useEffect(() => { let frameId: number | null = null if (permissionRequest) { frameId = requestAnimationFrame(() => { setCachedPermissionRequest(permissionRequest) }) } else if (toolDone) { frameId = requestAnimationFrame(() => { setCachedPermissionRequest(undefined) }) } return () => { if (frameId !== null) cancelAnimationFrame(frameId) } }, [permissionRequest, toolDone]) const effectivePermissionRequest = permissionRequest || cachedPermissionRequest const isFilePermission = effectivePermissionRequest?.permission === 'edit' || effectivePermissionRequest?.permission === 'write' // 权限已批准但工具还没完成 → 保留渲染 const permissionResolved = !permissionRequest && !!cachedPermissionRequest && isFilePermission && !toolDone const hasPendingInteraction = !!permissionRequest || !!questionRequest // 精简模式:非 edit/write 权限时不隐藏 ToolBody(ToolBody 已经渲染了命令内容) const isEditWritePermission = permissionRequest?.permission === 'edit' || permissionRequest?.permission === 'write' || permissionResolved const hideToolBodyForPermission = isEditWritePermission // 精简模式:ToolBody 已渲染时,InlinePermission 只显示按钮 // task 工具除外:task renderer 无法显示详细的工具请求内容,需要完整展示权限信息 const isTaskTool = toolName.toLowerCase() === 'task' const permissionContentHidden = compactInlinePermission && !isEditWritePermission && !isTaskTool && !!permissionRequest const isReadable = isReadableTool(toolName) const shouldStartExpanded = isActive || hasPendingInteraction || permissionResolved || (immersiveMode && descriptive && isStreaming && isReadable) const [expanded, setExpanded] = useState(() => shouldStartExpanded) const hasAutoExpandedReadableRef = useRef(shouldStartExpanded && immersiveMode && descriptive && isReadable) const effectiveExpanded = expanded || hasPendingInteraction || permissionResolved const shouldRenderBody = useDelayedRender(effectiveExpanded) useEffect(() => { let frameId: number | null = null if (isActive || hasPendingInteraction || permissionResolved) { if (immersiveMode && descriptive && isReadable) { hasAutoExpandedReadableRef.current = true } frameId = requestAnimationFrame(() => { setExpanded(true) }) } else if (immersiveMode && descriptive && !isReadable) { frameId = requestAnimationFrame(() => { setExpanded(false) }) } else if (immersiveMode && descriptive && isStreaming && isReadable && !hasAutoExpandedReadableRef.current) { hasAutoExpandedReadableRef.current = true frameId = requestAnimationFrame(() => { setExpanded(true) }) } return () => { if (frameId !== null) cancelAnimationFrame(frameId) } }, [isActive, hasPendingInteraction, permissionResolved, immersiveMode, descriptive, isStreaming, isReadable]) // Shared icon element const toolIcon = (
{getToolIcon(toolName)}
) // 需要渲染权限组件的请求对象:优先用活跃的,否则用缓存的(resolved 态) const displayPermission = permissionRequest || (permissionResolved ? cachedPermissionRequest : undefined) // Memoize once — shared by both the descriptive header (diffStats) and ToolBody. const toolData = useMemo(() => extractToolData(part), [part]) const bodyContent = ( <> {!hideToolBodyForPermission && } {displayPermission && (
)} {questionRequest && (
)} ) if (descriptive) { const hasDiffFiles = !!toolData.files?.length // diffStats 可能从 metadata 来,也可能需要从 diff 数据计算 const diffStats = toolData.diffStats || computeDiffStats(toolData) return (
{shouldRenderBody &&
{bodyContent}
}
) } // ── Compact layout (single-tool, no timeline) ── // Grid: [14px icon] [gap 6px] [content] — mirrors ReasoningPartView alignment if (compact) { return (
{/* Icon column — fixed, outside of interactive area */} {toolIcon} {/* Content column */}
{/* Body */}
{shouldRenderBody &&
{bodyContent}
}
) } // ── Timeline layout (multi-tool groups) ── return (
{/* Timeline Column */}
{/* Top connector — 留 4px gap 到 icon */} {!isFirst &&
} {/* Tool icon — h-9 和右侧 header 等高,flex 自然居中 */}
{toolIcon}
{/* Bottom connector — 留 4px gap 到 icon */} {!isLast &&
}
{/* Content Column */}
{/* Header - h-9 和 timeline 图标行等高 */} {/* Body - grid collapse */}
{shouldRenderBody &&
{bodyContent}
}
) }) // ============================================ // Helpers // ============================================ /** 用户需要阅读/交互的工具 */ const READABLE_TOOL_PATTERNS = /bash|\bsh\b|cmd|terminal|shell|write|save|edit|replace|patch|todo|question|ask/i function isReadableTool(toolName: string): boolean { return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase()) } // ============================================ // ToolBody - 根据工具类型选择渲染器 // ============================================ const ToolBody = memo(function ToolBody({ part, data }: { part: ToolPart; data: ReturnType }) { const { tool } = part const lowerTool = tool.toLowerCase() if (lowerTool === 'task') { return } if (lowerTool.includes('todo') && hasTodos(part)) { return } const config = getToolConfig(tool) if (config?.renderer) { const CustomRenderer = config.renderer return } return }) function getTaskChildSessionId(part: ToolPart): string | undefined { if (part.tool.toLowerCase() !== 'task') return undefined const metadata = part.state?.metadata as Record | undefined return metadata?.sessionId as string | undefined } /** Extract description from tool input as title fallback (available while running) */ function getInputDescription(part: ToolPart): string | undefined { const input = part.state?.input as Record | undefined return (input?.description as string) || undefined } // ============================================ // Helpers // ============================================