diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index fa3ed749b..c65f50c6e 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -317,6 +317,19 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD const { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } = useDragDrop(onDrop); const visibleMessages = messages.filter((m) => m.role === "user" || m.role === "assistant"); + // Stable Map identity: `messages` doesn't change during streaming updates + // (the streaming message lives in streamState), so memoized MessageViews + // skip re-rendering on every message_update event. An inline `new Map()` + // here used to defeat MessageView's memo() on each streamed chunk. + const toolResultsMap = useMemo(() => { + const map = new Map(); + for (const msg of messages) { + if (msg.role === "toolResult") { + map.set((msg as ToolResultMessage).toolCallId, msg as ToolResultMessage); + } + } + return map; + }, [messages]); const inputHistory = useMemo(() => { const seen = new Set(); const history: string[] = []; @@ -637,13 +650,6 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD
{(() => { - const toolResultsMap = new Map(); - for (const msg of messages) { - if (msg.role === "toolResult") { - toolResultsMap.set((msg as ToolResultMessage).toolCallId, msg as ToolResultMessage); - } - } - let lastUserIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "user") { lastUserIdx = i; break; } diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index cc6bd6822..2547f3393 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -27,7 +27,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile if (lang === "mermaid") { return ; } - return ; + return ; } return ( { assert.match(html, /mermaid-block-loading/); }); +function renderCode(props) { + return renderToStaticMarkup( + React.createElement( + I18nProvider, + null, + React.createElement(CodeBlock, props), + ), + ); +} + +test("CodeBlock highlights code when not streaming", () => { + const html = renderCode({ code: "const x = 1;", lang: "javascript" }); + + assert.match(html, /class="token/); + assert.match(html, /const/); +}); + +test("CodeBlock renders plain text without tokenization while streaming", () => { + const html = renderCode({ code: "const x = 1;", lang: "javascript", isStreaming: true }); + + assert.doesNotMatch(html, /class="token/); + assert.match(html, /const x = 1;/); +}); + test("MermaidBlock handles Chinese characters in diagram", () => { const chineseMermaid = `sequenceDiagram participant PC as PC客户端 diff --git a/components/MermaidBlock.tsx b/components/MermaidBlock.tsx index 0efd3db95..6826e29af 100644 --- a/components/MermaidBlock.tsx +++ b/components/MermaidBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; @@ -69,7 +69,7 @@ export function MermaidBlock({ code, isStreaming, defaultPreview = false }: Merm }; }, [code, currentKey, isDark, previewVisible]); - const previewButton = ( + const previewButton = useMemo(() => ( - ); + ), [isStreaming, previewVisible, t]); if (!previewVisible) { - return ; + return ; } const body = renderState?.key === currentKey && renderState.status === "error" ? ( @@ -224,13 +224,20 @@ interface CodeBlockProps { code: string; lang: string; headerAction?: ReactNode; + isStreaming?: boolean; } /** * Syntax-highlighted code block with copy button. * Used as the "source" view for mermaid blocks and for all non-mermaid code fences. + * + * Memoized: parent markdown re-renders (e.g. streaming updates elsewhere in + * the message list) must not re-run Prism tokenization on unchanged code. + * While the owning message is still streaming, the block renders as plain + * monospace text — highlighting a growing block re-tokenizes all of it on + * every chunk, which is the single most expensive part of streamed rendering. */ -export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) { +export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isStreaming }: CodeBlockProps) { const { isDark } = useTheme(); const { t } = useI18n(); const [copied, setCopied] = useState(false); @@ -256,23 +263,38 @@ export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) {
- - {code} - + {isStreaming ? ( +
+          {code}
+        
+ ) : ( + + {code} + + )} ); -} +});