import React from 'react'; import { useMobileAppActions } from '@/apps/mobileAppContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { QuestionMarkdown } from '../../QuestionMarkdown'; import { MessageFilesDisplay } from '../../FileAttachment'; import { getToolMetadata } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2'; import { toolDisplayStyles } from '@/lib/typography'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; import { useUIStore } from '@/stores/useUIStore'; import { sessionEvents } from '@/lib/sessionEvents'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; import { Text } from '@/components/ui/text'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { copyTextToClipboard } from '@/lib/clipboard'; import type { ToolPopupContent } from '../types'; import { PlainDiffFallback } from './PlainDiffFallback'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { formatEditOutput, detectLanguageFromOutput, formatInputForDisplay, renderTodoOutput, tryParseJsonOutput, coerceToText, } from '../toolRenderers'; import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer'; import { JsonSummaryView } from './JsonSummaryView'; import { Icon } from "@/components/icon/Icon"; import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle'; import { MinDurationShineText } from './MinDurationShineText'; import { ToolRevealOnMount } from './ToolRevealOnMount'; import { getToolIcon } from './toolPresentation'; import { useDurationTickerNow } from '@/hooks/useDurationTicker'; import { buildTaskSummaryEntriesFromSession, normalizeTaskSummaryEntries, parseTaskMetadataBlock, readTaskSessionIdFromOutput, readTaskSessionIdFromRecord, stripTaskMetadataFromOutput, type TaskToolSummaryEntry, } from './taskToolModel'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; import { extractFirstChangedLineFromDiff, getDiffPatchEntries, getFirstChangedLineFromMetadata, getMutatedToolPaths, getPatchText, getPrimaryDiffFromMetadata, getPrimaryToolPath, type DiffPatchEntry, } from './toolDiffUtils'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { getStreamingOutputAppend, getToolOutput } from './toolOutput'; import { toAbsoluteFilePath } from '@/lib/path-utils'; import { getToolDescriptionFallback } from './toolRenderUtils'; import { ApplyPatchFileButtons } from './ApplyPatchFileButtons'; import { openApplyPatchFileInEditor } from './applyPatchEditorAction'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS); type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number }; attachments?: Array }; interface ToolPartProps { part: ToolPartType; isExpanded: boolean; onToggle: (toolId: string) => void; isMobile: boolean; alwaysShowActions?: boolean; onShowPopup?: (content: ToolPopupContent) => void; animateTailText?: boolean; } const normalizeToolName = (toolName: string | undefined | null): string => { if (typeof toolName !== 'string') { return ''; } const trimmed = toolName.trim().toLowerCase(); if (!trimmed) { return ''; } if (trimmed.includes('.')) { const dotParts = trimmed.split('.').filter(Boolean); const last = dotParts[dotParts.length - 1]; if (last) return last; } return trimmed; }; const GIT_REFRESH_MUTATING_TOOLS = new Set([ 'bash', 'edit', 'write', 'apply_patch', 'patch', ]); const formatDuration = (start: number, end?: number, now: number = Date.now()) => { const duration = Math.max(0, (end ?? now) - start); const seconds = duration / 1000; const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds; return `${displaySeconds.toFixed(1)}s`; }; const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => { const now = useDurationTickerNow(active, 250); return <>{formatDuration(start, end, now)}; }; const deferredToolBodyMounts: Array<{ active: boolean; fn: () => void }> = []; let deferredToolBodyFrame: number | undefined; const flushDeferredToolBodyMounts = () => { while (deferredToolBodyMounts.length > 0) { const item = deferredToolBodyMounts.pop(); if (!item) { break; } if (item.active) { item.fn(); deferredToolBodyFrame = deferredToolBodyMounts.length > 0 ? window.requestAnimationFrame(flushDeferredToolBodyMounts) : undefined; return; } } deferredToolBodyFrame = undefined; }; const scheduleDeferredToolBodyMount = (fn: () => void) => { if (typeof window === 'undefined') { fn(); return () => undefined; } const item = { active: true, fn }; deferredToolBodyMounts.push(item); if (deferredToolBodyFrame === undefined) { deferredToolBodyFrame = window.requestAnimationFrame(() => { deferredToolBodyFrame = window.requestAnimationFrame(flushDeferredToolBodyMounts); }); } return () => { item.active = false; }; }; const useDeferredExpandedContent = (isExpanded: boolean) => { // If the tool is expanded when the row first mounts (e.g. "show tools open // by default", or scrolling a default-open tool back into a virtualized // view), render the body SYNCHRONOUSLY so the virtualizer measures the real // height immediately. Deferring it would let the row mount short and grow a // frame later, which makes the virtualizer compensate scroll and lurch the // viewport past several messages on slow scroll. Only defer LATER // user-initiated expansions, where instant single-item feedback isn't worth // blocking the click on a heavy body render. const [shouldRender, setShouldRender] = React.useState(isExpanded); const mountedRef = React.useRef(false); React.useEffect(() => { if (!isExpanded) { mountedRef.current = true; setShouldRender(false); return; } if (!mountedRef.current) { mountedRef.current = true; setShouldRender(true); return; } return scheduleDeferredToolBodyMount(() => { setShouldRender(true); }); }, [isExpanded]); return shouldRender; }; const parseDiffStats = (metadata?: Record): { added: number; removed: number } | null => { const diffText = getPatchText((metadata as { patch?: unknown } | undefined)?.patch) ?? getPatchText(metadata?.diff); if (!diffText) return null; let added = 0; let removed = 0; let lineStart = 0; for (let index = 0; index <= diffText.length; index += 1) { if (index < diffText.length && diffText.charCodeAt(index) !== 10) { continue; } const line = diffText.slice(lineStart, index); if (line.startsWith('+') && !line.startsWith('+++')) added++; if (line.startsWith('-') && !line.startsWith('---')) removed++; lineStart = index + 1; } if (added === 0 && removed === 0) return null; return { added, removed }; }; const parseWriteLineCount = (input?: Record): number | null => { if (!input?.content || typeof input.content !== 'string') return null; let lines = 1; for (let index = 0; index < input.content.length; index += 1) { if (input.content.charCodeAt(index) === 10) { lines += 1; } } return lines; }; const buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => { const normalizedContent = content.replace(/\r\n/g, '\n'); if (!normalizedContent.trim()) { return undefined; } const normalizedPath = (() => { const candidate = (filePath ?? '').trim(); if (!candidate) { return 'new-file'; } return candidate.startsWith('/') ? candidate.slice(1) : candidate; })(); const lines = normalizedContent.split('\n'); const hunkSize = lines.length; const body = lines.map((line) => `+${line}`).join('\n'); return [ '--- /dev/null', `+++ b/${normalizedPath}`, `@@ -0,0 +1,${hunkSize} @@`, body, ].join('\n'); }; const normalizeDisplayPath = (value: string): string => { const trimmed = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/'); if (!trimmed || trimmed === '/') { return trimmed; } return trimmed.replace(/\/+$/, ''); }; const getRelativePath = (absolutePath: string, currentDirectory: string): string => { const normalizedAbsolutePath = normalizeDisplayPath(absolutePath); const normalizedCurrentDirectory = normalizeDisplayPath(currentDirectory); if (!normalizedAbsolutePath) { return ''; } if (!normalizedCurrentDirectory) { return normalizedAbsolutePath; } if (normalizedAbsolutePath === normalizedCurrentDirectory) { return '.'; } const prefix = `${normalizedCurrentDirectory}/`; if (normalizedAbsolutePath.startsWith(prefix)) { return normalizedAbsolutePath.slice(prefix.length); } return normalizedAbsolutePath; }; type ToolDiagnostic = { message: string; line: number; character: number; }; type ToolDiagnosticSection = { displayPath: string; diagnostics: ToolDiagnostic[]; remaining: number; }; const TOOL_DIAGNOSTICS_MAX_PER_FILE = 5; const isRecord = (value: unknown): value is Record => { return typeof value === 'object' && value !== null; }; const normalizeToolDiagnostic = (value: unknown): ToolDiagnostic | null => { if (!isRecord(value)) { return null; } const message = typeof value.message === 'string' ? value.message.trim() : ''; if (!message) { return null; } const severity = typeof value.severity === 'number' && Number.isFinite(value.severity) ? Math.trunc(value.severity) : undefined; if (severity !== undefined && severity !== 1) { return null; } const range = isRecord(value.range) ? value.range : undefined; const start = range && isRecord(range.start) ? range.start : undefined; const rawLine = typeof start?.line === 'number' && Number.isFinite(start.line) ? Math.max(0, Math.trunc(start.line)) : 0; const rawCharacter = typeof start?.character === 'number' && Number.isFinite(start.character) ? Math.max(0, Math.trunc(start.character)) : 0; return { message, line: rawLine + 1, character: rawCharacter + 1, }; }; const getToolDiagnosticSection = ( toolName: string, input: Record | undefined, metadata: Record | undefined, currentDirectory: string, ): ToolDiagnosticSection | null => { if (!['edit', 'multiedit', 'write', 'apply_patch'].includes(toolName)) { return null; } const primaryPath = getPrimaryToolPath(toolName, input, metadata); if (!primaryPath || !metadata || !isRecord(metadata.diagnostics)) { return null; } const normalizedPath = normalizeDisplayPath(primaryPath); const absolutePath = normalizedPath.startsWith('/') ? normalizedPath : `${normalizeDisplayPath(currentDirectory)}/${normalizedPath}`.replace(/\/+/g, '/'); const rawDiagnostics = (metadata.diagnostics as Record)[normalizedPath] ?? (metadata.diagnostics as Record)[absolutePath]; if (!Array.isArray(rawDiagnostics)) { return null; } const diagnostics = rawDiagnostics .map((entry) => normalizeToolDiagnostic(entry)) .filter((entry): entry is ToolDiagnostic => !!entry); if (diagnostics.length === 0) { return null; } const visible = diagnostics.slice(0, TOOL_DIAGNOSTICS_MAX_PER_FILE); return { displayPath: normalizedPath.startsWith('/') ? getRelativePath(normalizedPath, currentDirectory) : normalizedPath, diagnostics: visible, remaining: Math.max(0, diagnostics.length - visible.length), }; }; // Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..." const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => { const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s); if (!match) return null; const pairs: Array<{ question: string; answer: string }> = []; const content = match[1]; // Match "question"="answer" pairs, handling multiline answers const pairRegex = /"([^"]+)"="([^"]*(?:[^"\\]|\\.)*)"/g; let pairMatch; while ((pairMatch = pairRegex.exec(content)) !== null) { pairs.push({ question: pairMatch[1], answer: pairMatch[2], }); } return pairs.length > 0 ? pairs : null; }; const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string | null => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; if (part.tool === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata?.files : []; const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined; const filePath = firstFile?.relativePath || firstFile?.filePath; if (files.length > 1) return null; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); } return null; } if ((part.tool === 'edit' || part.tool === 'multiedit') && input) { const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); } } if (part.tool === 'read' && input) { const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); } } if (['write', 'create', 'file_write'].includes(part.tool) && input) { const filePath = input?.filePath || input?.file_path || input?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); } } if (part.tool === 'lsp' && input) { const filePath = input?.filePath || input?.file_path || input?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); } } return null; }; const getLspToolDescription = (input: Record | undefined, currentDirectory: string): string => { if (!input) { return ''; } const operation = typeof input.operation === 'string' ? input.operation : 'lsp'; if (operation === 'workspaceSymbol') { const query = typeof input.query === 'string' && input.query.trim().length > 0 ? ` "${input.query.trim()}"` : ''; return `${operation}${query}`; } const filePath = typeof input.filePath === 'string' ? input.filePath : typeof input.file_path === 'string' ? input.file_path : typeof input.path === 'string' ? input.path : ''; const displayPath = filePath ? getRelativePath(filePath, currentDirectory) : ''; if (operation === 'documentSymbol') { return displayPath ? `${operation} ${displayPath}` : operation; } const line = typeof input.line === 'number' && Number.isFinite(input.line) ? Math.trunc(input.line) : undefined; const character = typeof input.character === 'number' && Number.isFinite(input.character) ? Math.trunc(input.character) : undefined; const position = line !== undefined && character !== undefined ? `:${line}:${character}` : ''; return displayPath ? `${operation} ${displayPath}${position}` : operation; }; const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const filePathLabel = getToolDescriptionPath(part, state, currentDirectory); if (filePathLabel) { return filePathLabel; } if (part.tool === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata?.files : []; if (files.length > 1) { return `${files.length} files`; } return ''; } // Question tool: show "Asked N question(s)" if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) { const count = input.questions.length; return `Asked ${count} question${count !== 1 ? 's' : ''}`; } if (part.tool === 'bash' && input?.command && typeof input.command === 'string') { const firstLine = input.command.split('\n')[0]; return firstLine.substring(0, 100); } if (part.tool === 'task' && input?.description && typeof input.description === 'string') { return input.description.substring(0, 80); } if (part.tool === 'lsp') { return getLspToolDescription(input, currentDirectory); } const desc = input?.description || metadata?.description || ('title' in state && state.title) || ''; return getToolDescriptionFallback(part.tool, desc, input); }; interface ToolScrollableSectionProps { children: React.ReactNode; maxHeightClass?: string; className?: string; outerClassName?: string; disableHorizontal?: boolean; followKey?: string; } const ToolScrollableSection: React.FC = ({ children, maxHeightClass = 'max-h-[60vh]', className, outerClassName, disableHorizontal = false, followKey, }) => { const scrollRef = React.useRef(null); const isFollowingRef = React.useRef(true); React.useLayoutEffect(() => { const element = scrollRef.current; if (followKey === undefined) { isFollowingRef.current = true; return; } if (!element || !isFollowingRef.current) { return; } element.scrollTop = element.scrollHeight; }, [followKey]); return (
{ if (followKey !== undefined && event.deltaY < 0) { isFollowingRef.current = false; } }} onScroll={(event) => { if (followKey === undefined) { return; } const element = event.currentTarget; isFollowingRef.current = element.scrollHeight - element.scrollTop - element.clientHeight <= 2; }} className={cn( 'tool-output-surface p-2 rounded-xl w-full min-w-0', maxHeightClass, disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto', className, )} >
{children}
); }; const getToolOutputLanguage = ( output: string, part: ToolPartType, metadata: Record | undefined, input: Record | undefined, ): string => { if (part.tool === 'bash') { return 'bash'; } return detectLanguageFromOutput(formatEditOutput(output, part.tool, metadata), part.tool, input); }; const getToolOutputText = ( output: string, part: ToolPartType, metadata: Record | undefined, ): string => { if (part.tool === 'bash') { return output; } return formatEditOutput(output, part.tool, metadata); }; const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => { const preRef = React.useRef(null); const previousOutputRef = React.useRef(''); React.useLayoutEffect(() => { const element = preRef.current; if (!element) { return; } const firstChild = element.firstChild; const textNode = firstChild instanceof globalThis.Text ? firstChild : document.createTextNode(''); if (textNode !== firstChild) { element.replaceChildren(textNode); } const append = getStreamingOutputAppend(previousOutputRef.current, output); if (append === undefined) { textNode.data = output; } else if (append.length > 0) { textNode.appendData(append); } previousOutputRef.current = output; }, [output]); return (
    );
};

const ToolScrollableTextOutput: React.FC<{
    output: string;
    part: ToolPartType;
    metadata: Record | undefined;
    input: Record | undefined;
    isStreaming?: boolean;
}> = ({ output, part, metadata, input, isStreaming = false }) => {
    const { t } = useI18n();
    const renderedOutput = getToolOutputText(output, part, metadata);
    const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
    const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
    const [jsonViewMode, setJsonViewMode] = React.useState<'summary' | 'formatted' | 'raw'>('summary');
    const [copiedJson, setCopiedJson] = React.useState(false);

    React.useEffect(() => {
        setJsonViewMode('summary');
        setCopiedJson(false);
    }, [renderedOutput]);

    const handleJsonViewChange = React.useCallback((view: 'summary' | 'formatted' | 'raw', event: React.MouseEvent) => {
        event.stopPropagation();
        setJsonViewMode(view);
    }, []);

    const handleCopyOutput = React.useCallback(async (event: React.MouseEvent) => {
        event.stopPropagation();
        const result = await copyTextToClipboard(renderedOutput);
        if (!result.ok) {
            toast.error(t('chat.toolPart.copyOutputFailed'));
            return;
        }
        setCopiedJson(true);
        if (typeof window !== 'undefined') {
            window.setTimeout(() => setCopiedJson(false), 1200);
        }
    }, [renderedOutput, t]);

    if (part.tool === 'bash' && isStreaming) {
        return (
            
); } if (jsonResult.isJson) { return (
{jsonViewMode === 'summary' ? ( ) : jsonViewMode === 'formatted' ? ( ) : (
)}
); } return (
); }; ToolScrollableTextOutput.displayName = 'ToolScrollableTextOutput'; const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { const title = entry.state?.title; if (typeof title === 'string' && title.trim().length > 0) { return title; } const input = entry.state?.input; if (input && typeof input === 'object') { const pathCandidate = input.filePath ?? input.file_path ?? input.path; if (typeof pathCandidate === 'string' && pathCandidate.trim().length > 0) { return pathCandidate.trim(); } const urlCandidate = input.url; if (typeof urlCandidate === 'string' && urlCandidate.trim().length > 0) { return urlCandidate.trim(); } } return ''; }; const FILE_PATH_LABEL_TOOLS = new Set([ 'read', 'view', 'file_read', 'cat', 'write', 'create', 'file_write', 'edit', 'multiedit', 'apply_patch', ]); const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => { if (!FILE_PATH_LABEL_TOOLS.has(toolName.toLowerCase())) { return false; } const trimmed = label.trim(); if (!trimmed || trimmed === 'Patch' || /^\d+\s+files$/.test(trimmed)) { return false; } if (trimmed.includes('/') || trimmed.includes('\\')) { return true; } const baseName = trimmed.split(/[\\/]/).pop() || trimmed; if (baseName.startsWith('.') || baseName.includes('.')) { return true; } return /^[A-Za-z0-9_-]+$/.test(baseName); }; const getTaskSummaryEntryRenderSignature = (entry: TaskToolSummaryEntry): string => { const toolName = normalizeToolName(entry.tool); const status = entry.state?.status ?? ''; const label = getTaskSummaryLabel(entry); return `${entry.id ?? ''}\u0001${toolName}\u0001${status}\u0001${label}`; }; const areTaskSummaryEntriesRenderEqual = ( prevEntries: TaskToolSummaryEntry[], nextEntries: TaskToolSummaryEntry[], ): boolean => { if (prevEntries === nextEntries) return true; if (prevEntries.length !== nextEntries.length) return false; for (let index = 0; index < prevEntries.length; index += 1) { if (getTaskSummaryEntryRenderSignature(prevEntries[index]) !== getTaskSummaryEntryRenderSignature(nextEntries[index])) { return false; } } return true; }; const TaskSummaryEntryRow = React.memo(({ entry, isMobile, animateTailText, showToolFileIcons, }: { entry: TaskToolSummaryEntry; isMobile: boolean; animateTailText: boolean; showToolFileIcons: boolean; }) => { const normalizedToolName = normalizeToolName(entry.tool); const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool'; const label = getTaskSummaryLabel(entry); const hasLabel = label.trim().length > 0; const status = entry.state?.status; const displayName = getToolMetadata(toolName).displayName; return ( {/* Single-line rows everywhere: the old mobile break-words mode wrapped long shell commands into a hanging column and floated the icon to the top of the block. Errors still wrap — they must stay readable. */}
{getToolIcon(toolName)} {displayName} {hasLabel ? ( status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? ( renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons) ) : ( status === 'error' ? ( {label} ) : ( {label} ) ) ) : null}
); }, (prev, next) => { return prev.isMobile === next.isMobile && prev.animateTailText === next.animateTailText && prev.showToolFileIcons === next.showToolFileIcons && getTaskSummaryEntryRenderSignature(prev.entry) === getTaskSummaryEntryRenderSignature(next.entry); }); TaskSummaryEntryRow.displayName = 'TaskSummaryEntryRow'; const TaskSummaryEntriesList = React.memo(({ entries, isExpanded, isMobile, animateTailText, showToolFileIcons, }: { entries: TaskToolSummaryEntry[]; isExpanded: boolean; isMobile: boolean; animateTailText: boolean; showToolFileIcons: boolean; }) => { const visibleEntries = isExpanded ? entries : entries.slice(-6); const hiddenCount = Math.max(0, entries.length - visibleEntries.length); const visibleStartIndex = entries.length - visibleEntries.length; return (
{hiddenCount > 0 ? (
+{hiddenCount} more…
) : null} {visibleEntries.map((entry, idx) => { const absoluteIndex = isExpanded ? idx : visibleStartIndex + idx; const rowKey = entry.id ?? `${getTaskSummaryEntryRenderSignature(entry)}:${absoluteIndex}`; return ( ); })}
); }, (prev, next) => { return prev.isExpanded === next.isExpanded && prev.isMobile === next.isMobile && prev.animateTailText === next.animateTailText && prev.showToolFileIcons === next.showToolFileIcons && areTaskSummaryEntriesRenderEqual(prev.entries, next.entries); }); TaskSummaryEntriesList.displayName = 'TaskSummaryEntriesList'; const TaskToolSummary: React.FC<{ entries: TaskToolSummaryEntry[]; isExpanded: boolean; isMobile: boolean; output?: string; sessionId?: string; onShowPopup?: (content: ToolPopupContent) => void; input?: Record; animateTailText?: boolean; isActive?: boolean; }> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => { const { t } = useI18n(); const currentDirectory = useEffectiveDirectory(); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); const runtime = React.useContext(RuntimeAPIContext); const trimmedOutput = typeof output === 'string' ? stripTaskMetadataFromOutput(output) : ''; const hasOutput = trimmedOutput.length > 0; const [isOutputExpanded, setIsOutputExpanded] = React.useState(false); const handleOpenSession = (event: React.MouseEvent) => { event.stopPropagation(); if (sessionId && currentDirectory) { // In contexts with no ContextPanel (embedded session-chat iframe) // or single-surface layouts (mobile, VS Code), navigate in place. // Otherwise open a new side-panel tab. if (isEmbeddedSessionChat() || isMobile || runtime?.runtime.isVSCode) { setCurrentSession(sessionId, currentDirectory); return; } openContextPanelTab(currentDirectory, { mode: 'chat', dedupeKey: `session:${sessionId}`, label: agentType.charAt(0).toUpperCase() + agentType.slice(1), readOnly: true, }); } }; const agentType = typeof input?.subagent_type === 'string' ? input.subagent_type : 'subagent'; if (entries.length === 0 && !hasOutput && !sessionId) { return (
{isActive ? 'Waiting for subagent activity...' : 'No subagent session id on task metadata.'}
); } return (
{entries.length > 0 ? ( ) : null} {sessionId && ( )} {hasOutput ? (
0 || sessionId) && 'pt-1')} > {isOutputExpanded ? (
) : null}
) : null}
); }; const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = { ...toolDisplayStyles.getCollapsedStyles(), padding: 0, overflow: 'visible', }; const CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent' } }; const TOOL_ERROR_ICON_STYLE: React.CSSProperties = { color: 'var(--status-error)' }; const TOOL_NORMAL_ICON_STYLE: React.CSSProperties = { color: 'var(--tools-icon)' }; const TOOL_ERROR_TITLE_STYLE: React.CSSProperties = { color: 'var(--status-error)' }; const TOOL_NORMAL_TITLE_STYLE: React.CSSProperties = { color: 'var(--tools-title)' }; const renderPathLikeGitChanges = (path: string, grow = true) => { const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) { return ( {path} ); } const dir = path.slice(0, lastSlash); const name = path.slice(lastSlash + 1); const hasAbsoluteRoot = dir.startsWith('/'); const displayDir = hasAbsoluteRoot ? dir.slice(1) : dir; return ( {hasAbsoluteRoot ? / : null} {displayDir} / {name} ); }; const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, showFileIcons = true) => { const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) { return ( {showFileIcons ? : null} {path} ); } const dir = path.slice(0, lastSlash); const name = path.slice(lastSlash + 1); const hasAbsoluteRoot = dir.startsWith('/'); const displayDir = hasAbsoluteRoot ? dir.slice(1) : dir; return ( {showFileIcons ? : null} {hasAbsoluteRoot ? / : null} {displayDir} / {name} ); }; // The rich diff preview is the only tool-card piece that needs the // @pierre/diffs + Shiki stack; lazy-loading it keeps that stack out of the // eager chat graph. While the chunk loads, the plain-text patch renders as the // Suspense fallback, mirroring the preview's own error fallback. const LazyToolPartDiffPreview = lazyWithChunkRecovery(() => import('./ToolPartDiffPreview')); const DiffPreview: React.FC<{ diff: string; diffViewMode: DiffViewMode }> = ({ diff, diffViewMode }) => ( }> ); interface ToolExpandedContentProps { part: ToolPartType; state: ToolStateUnion; currentDirectory: string; isExpanded: boolean; onShowPopup?: (content: ToolPopupContent) => void; } const ToolExpandedContent: React.FC = React.memo(({ part, state, currentDirectory, isExpanded, onShowPopup, }) => { const { t } = useI18n(); const runtime = React.useContext(RuntimeAPIContext); const mobileActions = useMobileAppActions(); const [diffViewMode, setDiffViewMode] = React.useState('unified'); const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output, state.status); const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0; const rawOutputString = typeof rawOutput === 'string' ? rawOutput : ''; const isStreamingBash = part.tool === 'bash' && state.status === 'running'; const throttledOutputString = useStreamingTextThrottle({ text: rawOutputString, isStreaming: isStreamingBash, identityKey: part.id, allowTextReplacement: isStreamingBash, }); const outputString = isStreamingBash ? throttledOutputString : rawOutputString; const attachments = stateWithData.attachments; const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch) ?? getPatchText(metadata?.diff) ?? getPatchText(fileDiff?.patch) ?? getPatchText(fileDiff?.diff) ?? null; const diffEntries = React.useMemo( () => getDiffPatchEntries(metadata, diffContent ?? undefined, (path) => getRelativePath(path, currentDirectory)), [currentDirectory, diffContent, metadata] ); const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff'); const hideToolInputPreview = part.tool === 'openchamber' || part.tool === 'openchamber_web' || part.tool === 'openchamber_memory' || part.tool === 'apply_patch' || part.tool === 'edit' || part.tool === 'multiedit'; const diagnosticSection = React.useMemo( () => getToolDiagnosticSection(part.tool, input, metadata, currentDirectory), [currentDirectory, input, metadata, part.tool], ); const inputTextContent = React.useMemo(() => { if (!input || typeof input !== 'object' || Object.keys(input).length === 0) { return ''; } if ('command' in input && typeof input.command === 'string' && part.tool === 'bash') { return formatInputForDisplay(input, part.tool); } if (typeof (input as { content?: unknown }).content === 'string') { return (input as { content?: string }).content ?? ''; } return formatInputForDisplay(input, part.tool); }, [input, part.tool]); const hasInputText = !hideToolInputPreview && inputTextContent.trim().length > 0; const isWriteLikeTool = part.tool === 'write' || part.tool === 'create' || part.tool === 'file_write'; const isTodoTool = part.tool === 'todowrite' || part.tool === 'todoread'; const todoContent = React.useMemo(() => { if (Array.isArray(input?.todos)) { return JSON.stringify(input.todos); } return outputString; }, [input?.todos, outputString]); const writeLikeInputPatch = React.useMemo(() => { if (!isWriteLikeTool || !hasInputText) { return undefined; } const filePath = typeof input?.filePath === 'string' ? input.filePath : typeof input?.file_path === 'string' ? input.file_path : typeof input?.path === 'string' ? input.path : undefined; return buildWritePreviewPatch(filePath, inputTextContent); }, [hasInputText, input?.filePath, input?.file_path, input?.path, inputTextContent, isWriteLikeTool]); React.useEffect(() => { setDiffViewMode('unified'); }, [part.id]); const renderScrollableBlock = ( content: React.ReactNode, options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string; followKey?: string } ) => ( {content} ); const renderResultContent = () => { const getEntryAbsolutePath = (entry: DiffPatchEntry) => toAbsoluteFilePath(currentDirectory, entry.filePath ?? entry.title); const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent) => { event.stopPropagation(); const line = extractFirstChangedLineFromDiff(entry.patch); const absolutePath = getEntryAbsolutePath(entry); if (runtime?.editor && runtime.runtime.isVSCode) { void runtime.editor.openFile(absolutePath, line); return; } useUIStore.getState().openContextFileAtLine(currentDirectory, absolutePath, line ?? 1, 1); // Dedicated mobile app: the pending file navigation is consumed by // the FilesView pane — surface it (workspace drawer Files tab). mobileActions?.openFiles(); }; const openEntryDiff = (entry: DiffPatchEntry, event: React.MouseEvent) => { event.stopPropagation(); const line = extractFirstChangedLineFromDiff(entry.patch); const absolutePath = getEntryAbsolutePath(entry); if (runtime?.editor && runtime.runtime.isVSCode) { void runtime.editor.openDiff('', absolutePath, `${getRelativePath(absolutePath, currentDirectory)} (changes)`, { line, patch: entry.patch }); return; } const store = useUIStore.getState(); const relativePath = getRelativePath(absolutePath, currentDirectory); if (store.isMobile) { store.navigateToDiff(relativePath); return; } store.openContextDiff(currentDirectory, relativePath); }; const renderDiagnosticsSection = () => { if (!diagnosticSection) { return null; } return (
{t('chat.toolPart.lspErrors')}
{renderPathLikeGitChanges(diagnosticSection.displayPath, false)}
{diagnosticSection.diagnostics.map((diagnostic, index) => (
[{diagnostic.line}:{diagnostic.character}] {diagnostic.message}
))}
{diagnosticSection.remaining > 0 ? (
{t('chat.toolPart.moreErrors', { count: diagnosticSection.remaining })}
) : null}
); }; // Question tool: show parsed Q&A summary or question content from input if (part.tool === 'question') { if (state.status === 'completed' && hasStringOutput) { const parsedQA = parseQuestionOutput(outputString); if (parsedQA && parsedQA.length > 0) { return renderScrollableBlock(
{parsedQA.map((qa, index) => (
{qa.answer}
))}
, { maxHeightClass: 'max-h-[40vh]' } ); } } if (state.status === 'error' && 'error' in state) { return (
{t('chat.toolPart.error')}
{coerceToText(state.error)}
); } // Show question content from input whenever available, whether the tool is // pending/running or completed without parseable output. This ensures question // text persists across refreshes even if the QuestionCard store data is lost. const questionInput = input as { questions?: Array<{ question?: string; header?: string; options?: Array<{ label: string; description: string }>; multiple?: boolean }> } | undefined; if (questionInput?.questions && Array.isArray(questionInput.questions) && questionInput.questions.length > 0) { return renderScrollableBlock(
{questionInput.questions.map((q, index) => (
{q.header ? (
{coerceToText(q.header)}
) : null} {Array.isArray(q.options) && q.options.length > 0 ? (
{q.options.map((opt) => ( {coerceToText(opt.label)} ))}
) : null}
))}
, { maxHeightClass: 'max-h-[40vh]' } ); } return
{t('chat.toolPart.awaitingResponse')}
; } if (part.tool === 'task' && hasStringOutput) { return renderScrollableBlock(
); } if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && (diffEntries.length > 0 || !!diagnosticSection)) { return renderScrollableBlock(
{diffEntries.map((entry) => (
{renderPathLikeGitChanges(entry.title)}
{entry.renderMode === 'diff' ? ( ) : ( )}
))} {renderDiagnosticsSection()}
, { className: 'p-1' } ); } if (part.tool === 'write' && diagnosticSection) { return renderScrollableBlock(
{renderDiagnosticsSection()}
, { className: 'p-1' }, ); } if (isWriteLikeTool) { return null; } if (hasStringOutput && outputString.trim()) { const output = ( ); return renderScrollableBlock( output, { className: part.tool === 'bash' ? 'p-1 rounded-none' : 'p-1', maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined, followKey: isStreamingBash ? outputString : undefined, } ); } return renderScrollableBlock(
{t('chat.toolPart.noOutputProduced')}
, { maxHeightClass: 'max-h-60' } ); }; const hasVisibleOutput = outputString.trim().length > 0; const shouldRenderResult = (state.status === 'completed' && 'output' in state) || (part.tool === 'bash' && hasVisibleOutput); if (isTodoTool) { if (state.status === 'error' && 'error' in state) { return (
{t('chat.toolPart.error')}
{state.error}
); } const todoOutput = renderTodoOutput(todoContent, { total: t('chat.todo.total'), inProgress: t('chat.todo.inProgress'), pending: t('chat.todo.pending'), completed: t('chat.todo.completed'), cancelled: t('chat.todo.cancelled'), }, { unstyled: true }); return (
{renderScrollableBlock( todoOutput ?? ( ), { className: 'p-2', maxHeightClass: 'max-h-[46vh]' }, )}
); } return (
{part.tool === 'question' ? ( renderResultContent() ) : ( <> {hasInputText ? (
{renderScrollableBlock( part.tool === 'bash' ? (
                                        {inputTextContent}
                                    
) : isWriteLikeTool && writeLikeInputPatch ? ( ) : (
{inputTextContent}
), { maxHeightClass: isWriteLikeTool && writeLikeInputPatch && isExpanded ? 'max-h-[50vh]' : 'max-h-60', className: part.tool === 'bash' ? 'tool-input-surface p-0 rounded-none' : 'tool-input-surface', } )}
) : null} {shouldRenderResult && (
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && hasVisualDiffEntry ? (
) : null} {renderResultContent()}
)} {state.status === 'error' && 'error' in state && (
{t('chat.toolPart.error')}
{coerceToText(state.error)}
)} )} {Array.isArray(attachments) && attachments.length > 0 && state.status === 'completed' ? ( ) : null}
); }); ToolExpandedContent.displayName = 'ToolExpandedContent'; const ToolPartContent: React.FC = ({ part, isExpanded, onToggle, isMobile, onShowPopup, animateTailText = true, }) => { const { t } = useI18n(); const state = part.state; const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const showToolFileIcons = useUIStore((s) => s.showToolFileIcons); const currentDirectory = useEffectiveDirectory() ?? ''; const normalizedPartTool = normalizeToolName(part.tool); const isTaskTool = normalizedPartTool === 'task'; const status = state?.status as string | undefined; const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled'; const isSuccessfullyFinalized = status === 'completed'; const isError = status === 'error' || status === 'failed'; const [activeLatched, setActiveLatched] = React.useState(!isFinalized); const previousPartIdRef = React.useRef(part.id); const observedActiveGitToolRef = React.useRef(!isFinalized); React.useEffect(() => { if (previousPartIdRef.current === part.id) { return; } previousPartIdRef.current = part.id; observedActiveGitToolRef.current = !isFinalized; // Reset latch only when tool identity changes. setActiveLatched(!isFinalized); }, [isFinalized, part.id]); React.useEffect(() => { if (!isFinalized) { setActiveLatched(true); } }, [isFinalized]); React.useEffect(() => { if (!isFinalized) { observedActiveGitToolRef.current = true; return; } // Historical completed tools can remount when the timeline changes. // Refresh only for a tool whose active state this instance observed. const finalizedAfterObservedActive = observedActiveGitToolRef.current; if (!finalizedAfterObservedActive) { return; } if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) { observedActiveGitToolRef.current = false; return; } if (!currentDirectory) { return; } observedActiveGitToolRef.current = false; const paths = getMutatedToolPaths(normalizedPartTool, input, metadata) .map((path) => getRelativePath(path, currentDirectory)); sessionEvents.requestGitRefresh({ directory: currentDirectory, ...(paths.length > 0 ? { paths } : {}), }); }, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]); const expandedContentRef = React.useRef(null); React.useLayoutEffect(() => { if (isTaskTool) { return; } const element = expandedContentRef.current; if (!element) { return; } element.style.height = isExpanded ? 'auto' : '0px'; element.style.overflow = isExpanded ? 'visible' : 'hidden'; }, [isExpanded, isTaskTool]); const partMetadata = (part as unknown as { metadata?: unknown }).metadata; const time = stateWithData.time; const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>(() => ({ start: typeof time?.start === 'number' ? time.start : undefined, end: typeof time?.end === 'number' ? time.end : undefined, })); const [localStartAt, setLocalStartAt] = React.useState(undefined); const [localFinalizedAt, setLocalFinalizedAt] = React.useState(undefined); React.useEffect(() => { setPinnedTime({}); setLocalStartAt(undefined); setLocalFinalizedAt(undefined); }, [part.id]); React.useEffect(() => { if (isFinalized) { return; } if (typeof time?.start === 'number') { return; } setLocalStartAt((prev) => prev ?? Date.now()); }, [isFinalized, time?.start]); React.useEffect(() => { setPinnedTime((prev) => { const next = { ...prev }; let changed = false; if (typeof time?.start === 'number' && (typeof prev.start !== 'number' || time.start < prev.start)) { next.start = time.start; changed = true; } if (typeof time?.end === 'number' && (typeof prev.end !== 'number' || time.end > prev.end)) { next.end = time.end; changed = true; } return changed ? next : prev; }); }, [time?.end, time?.start]); const effectiveTimeStart = React.useMemo(() => { // Once we captured a local start (during pending, before server sends time.start), // always prefer it so the timer never jumps when server start arrives later. if (typeof localStartAt === 'number') { return localStartAt; } const candidates = [pinnedTime.start, time?.start].filter( (value): value is number => typeof value === 'number' ); if (candidates.length === 0) { return undefined; } return Math.min(...candidates); }, [localStartAt, pinnedTime.start, time?.start]); const taskOutputString = React.useMemo(() => { return typeof stateWithData.output === 'string' ? stateWithData.output : undefined; }, [stateWithData.output]); const parsedTaskMetadata = React.useMemo(() => { return parseTaskMetadataBlock(taskOutputString); }, [taskOutputString]); const metadataTaskSummaryEntries = React.useMemo(() => { if (!isTaskTool) { return []; } const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined); const normalized = normalizeTaskSummaryEntries( candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls ); if (normalized.length > 0) { return normalized; } return parsedTaskMetadata.summaryEntries; }, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]); const hasFinalMetadataTaskSummary = isFinalized && metadataTaskSummaryEntries.length > 0; const taskSessionId = React.useMemo(() => { if (!isTaskTool) { return undefined; } // Current OpenCode publishes this authoritative join while the Task is // running. The remaining sources only support older persisted parts. const metadataSessionId = readTaskSessionIdFromRecord(metadata); if (metadataSessionId) { return metadataSessionId; } const partLevelSessionId = readTaskSessionIdFromRecord(partMetadata); if (partLevelSessionId) { return partLevelSessionId; } if (parsedTaskMetadata.sessionId) { return parsedTaskMetadata.sessionId; } return readTaskSessionIdFromOutput(taskOutputString); }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]); const childSessionLookupId = hasFinalMetadataTaskSummary ? '' : (taskSessionId ?? ''); const childSessionMessages = useSessionMessageRecords(childSessionLookupId, currentDirectory); useEnsureSessionMessages(childSessionLookupId, currentDirectory); const childSessionTaskSummaryEntries = React.useMemo(() => { if (!isTaskTool || !taskSessionId) { return []; } if (!Array.isArray(childSessionMessages) || childSessionMessages.length === 0) { return []; } return buildTaskSummaryEntriesFromSession(childSessionMessages); }, [childSessionMessages, isTaskTool, taskSessionId]); React.useEffect(() => { if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') { setLocalFinalizedAt(undefined); return; } if (typeof effectiveTimeStart !== 'number') { return; } if (!isFinalized) { return; } setLocalFinalizedAt((prev) => prev ?? Date.now()); }, [ effectiveTimeStart, isFinalized, pinnedTime.end, time?.end, ]); const effectiveTimeEnd = isFinalized ? (pinnedTime.end ?? time?.end ?? localFinalizedAt) : undefined; const isActive = !isFinalized && activeLatched; const shouldTreatAsFinalized = isFinalized; const taskSummaryEntries = React.useMemo(() => { if (childSessionTaskSummaryEntries.length > 0) { return childSessionTaskSummaryEntries; } return metadataTaskSummaryEntries; }, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]); const diffStats = React.useMemo(() => { return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') ? parseDiffStats(metadata) : null; }, [metadata, normalizedPartTool]); const writeLineCount = React.useMemo(() => { return normalizedPartTool === 'write' ? parseWriteLineCount(input) : null; }, [input, normalizedPartTool]); const isMultiFileApplyPatch = normalizedPartTool === 'apply_patch' && Array.isArray(metadata?.files) && (metadata?.files as []).length > 1; const normalizedPart = normalizedPartTool !== part.tool ? ({ ...part, tool: normalizedPartTool } as ToolPartType) : part; const descriptionPath = getToolDescriptionPath(normalizedPart, state, currentDirectory); const description = getToolDescription(normalizedPart, state, currentDirectory); const displayName = getToolMetadata(normalizedPartTool || part.tool).displayName; // Tool title/description — shown inline as context const justificationText = React.useMemo(() => { if (normalizedPartTool === 'bash') { return null; } if (normalizedPartTool === 'apply_patch') { return null; } if (normalizedPartTool === 'lsp') { return null; } if ( descriptionPath && (normalizedPartTool === 'apply_patch' || normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'write') ) { return null; } const title = (stateWithData as { title?: string }).title; if (typeof title === 'string' && title.trim().length > 0) { return title; } const inputDesc = input?.description; if (typeof inputDesc === 'string' && inputDesc.trim().length > 0) { return inputDesc; } return null; }, [descriptionPath, normalizedPartTool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); const openApplyPatchFile = (file: Record, event: React.MouseEvent) => { if (!runtime?.editor) { return; } event.stopPropagation(); const displayPath = typeof file.relativePath === 'string' ? file.relativePath : typeof file.filePath === 'string' ? getRelativePath(file.filePath, currentDirectory) : ''; openApplyPatchFileInEditor({ currentDirectory, diffLabel: `${displayPath} (changes)`, editor: runtime.editor, file, isVSCode: runtime.runtime.isVSCode, }); }; const handleMainClick = (e: { stopPropagation: () => void }) => { if (isTaskTool || !runtime?.editor) { onToggle(part.id); return; } let filePath: unknown; let targetLine: number | undefined; let toolDiff: string | undefined; if (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit') { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; if (typeof filePath === 'string') { toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath); } } else if (normalizedPartTool === 'apply_patch') { filePath = getPrimaryToolPath(normalizedPartTool, input, metadata); if (typeof filePath === 'string') { toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath); } } else if (['write', 'create', 'file_write'].includes(normalizedPartTool)) { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; } else if (normalizedPartTool === 'lsp') { filePath = input?.filePath || input?.file_path || input?.path; const line = input?.line; targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined; } if (typeof filePath === 'string') { e.stopPropagation(); const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); if (runtime.runtime.isVSCode && toolDiff && (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')) { const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`; void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff }); return; } runtime.editor.openFile(absolutePath, targetLine); } else { onToggle(part.id); } }; const handleMainKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); handleMainClick(event); }; const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE; const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE; const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId)); const shouldRenderExpandedContent = useDeferredExpandedContent(!isTaskTool && isExpanded); if (!shouldTreatAsFinalized && !isActive && !isTaskTool) { return null; } return (
{}
onToggle(part.id) : handleMainClick} onKeyDown={isMultiFileApplyPatch ? (event) => { if (event.target !== event.currentTarget) return; if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); onToggle(part.id); } : handleMainKeyDown} role="button" tabIndex={0} >
{isMultiFileApplyPatch ? ( <>
{getToolIcon(normalizedPartTool || part.tool)} {displayName}
) : ( <>
{ event.stopPropagation(); onToggle(part.id); }} >
{getToolIcon(normalizedPartTool || part.tool)}
{isExpanded ? : }
{displayName}
{normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? ( ) : null} )}
{!isMultiFileApplyPatch && (
{justificationText && ( {justificationText} )} {!justificationText && normalizedPartTool === 'lsp' && descriptionPath ? ( renderAnimatedPathWithIcon(descriptionPath, animateTailText, false, showToolFileIcons) ) : null} {!justificationText && normalizedPartTool !== 'lsp' && description && ( descriptionPath && description === descriptionPath ? ( renderAnimatedPathWithIcon(descriptionPath, animateTailText, false, showToolFileIcons) ) : ( {description} ) )} {diffStats && ( +{diffStats.added} / -{diffStats.removed} )} {writeLineCount && ( +{writeLineCount} )}
)}
{} {shouldRenderTaskSummary ? ( ) : null} {!isTaskTool ? (
{shouldRenderExpandedContent ? (
) : null}
) : null}
); }; class ToolPartErrorBoundary extends React.Component<{ children: React.ReactNode; displayName: string; errorLabel: string; resetKey: unknown; toolName: string; }, { hasError: boolean; error?: Error }> { state: { hasError: boolean; error?: Error } = { hasError: false }; static getDerivedStateFromError(error: Error): { hasError: boolean; error: Error } { return { hasError: true, error }; } componentDidUpdate(prevProps: { resetKey: unknown }) { if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { this.setState({ hasError: false, error: undefined }); } } componentDidCatch(error: Error) { if (process.env.NODE_ENV === 'development') { console.warn('Tool part failed to render; showing safe fallback.', error); } } render() { if (!this.state.hasError) { return this.props.children; } const message = this.state.error?.message; return (
{getToolIcon(this.props.toolName)}
{this.props.displayName} {message ? ( {this.props.errorLabel}: {message} ) : null}
); } } const ToolPart: React.FC = (props) => { const { t } = useI18n(); const toolName = normalizeToolName(props.part.tool) || 'tool'; const displayName = getToolMetadata(toolName).displayName; return ( ); }; export default React.memo(ToolPart, (prev, next) => { return areRenderRelevantPartsEqual([prev.part], [next.part]) && prev.isExpanded === next.isExpanded && prev.isMobile === next.isMobile && prev.alwaysShowActions === next.alwaysShowActions && prev.onShowPopup === next.onShowPopup && prev.animateTailText === next.animateTailText; });