mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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>
108 lines
3.6 KiB
TypeScript
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 ?? '')
|
|
}
|