cc-haha/desktop/src/components/chat/ToolResultBlock.tsx
程序员阿江(Relakkes) 659c63e5ba perf(desktop): memoize chat row subtree to stop window-slide re-renders
When the virtualization window slid during scroll, every visible row reconciled
from scratch because the heavy chat-row subtree had no memo barriers, and
because renderTranscriptItem rebuilt fresh branchAction / toolResult object
literals each render that broke MessageBlock's existing memo.

Wraps AssistantMessage, UserMessage, ToolCallBlock, ToolCallGroup, ToolResultBlock,
and MarkdownRenderer in React.memo, and hoists the per-message branchAction and
toolResult lookups into useMemo'd Maps keyed by message id. Window slides now
keep referentially-stable props for unchanged rows, so reconciliation skips them
and only newly entering rows pay full render cost.

Tested: 722/722 desktop vitest suites pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:41:25 +08:00

108 lines
3.6 KiB
TypeScript

import { CodeViewer } from './CodeViewer'
import { memo, useState } from 'react'
import { useTranslation } from '../../i18n'
import { InlineImageGallery } from './InlineImageGallery'
type Props = {
content: unknown
isError: boolean
toolName?: string
standalone?: boolean
}
/**
* Standalone tool result block — only shown when not already rendered
* inline within ToolCallBlock (i.e., when the tool_use and tool_result
* are NOT grouped together by MessageList).
*/
export const ToolResultBlock = memo(function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) {
const [expanded, setExpanded] = useState(false)
const t = useTranslation()
// Don't render standalone if this result is already rendered inline
if (!standalone) return null
const text = extractText(content)
const preview = text.slice(0, 200)
const hasMore = text.length > 200
return (
<div className={`mb-2 overflow-hidden rounded-xl border ${
isError
? 'border-[var(--color-error)]/20'
: 'border-[var(--color-outline-variant)]/20'
}`}>
{/* Status header */}
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-[10px] font-bold uppercase tracking-wider ${
isError
? 'bg-[var(--color-error-container)] text-[var(--color-error)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]'
}`}
>
<span className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[12px]">
{isError ? 'error' : 'check_circle'}
</span>
{toolName ? t('tool.result', { toolName }) : t('tool.resultGeneric')}
</span>
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
isError
? 'bg-[var(--color-error)]/10'
: 'bg-[var(--color-diff-added-bg)] text-[var(--color-diff-added-text)]'
}`}>
{isError ? t('tool.error') : t('tool.success')}
</span>
</button>
{/* Inline image gallery from detected paths */}
<InlineImageGallery text={text} />
{/* Content */}
{expanded ? (
isError ? (
<div className="bg-[var(--color-error-container)]/50 px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.5] whitespace-pre-wrap break-words text-[var(--color-error)]">
{text}
</div>
) : (
<CodeViewer
code={text}
language="plaintext"
maxLines={12}
/>
)
) : (
<div className="bg-[var(--color-surface-container-lowest)] px-3 py-2 font-[var(--font-mono)] text-[10px] leading-[1.35] text-[var(--color-text-tertiary)]">
{preview}
{hasMore ? '…' : ''}
</div>
)}
{hasMore && (
<button
onClick={() => setExpanded((value) => !value)}
className="w-full py-1 text-[10px] font-medium text-[var(--color-text-accent)] hover:underline bg-[var(--color-surface-container-low)] border-t border-[var(--color-outline-variant)]/10"
>
{expanded ? t('tool.showLess') : t('tool.showMore', { count: text.length - 200 })}
</button>
)}
</div>
)
})
function extractText(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((c: any) => (typeof c === 'string' ? c : c?.text || ''))
.filter(Boolean)
.join('\n')
}
if (content && typeof content === 'object') {
return JSON.stringify(content, null, 2)
}
return String(content ?? '')
}