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 = (