From 0138282f97c98f77571cab2aa78bda38abf9e5a0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 17 Jun 2026 08:22:14 -0500 Subject: [PATCH] perf(desktop): keep oversized messages from freezing the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-MB message (logged bundle, huge tool dump) froze the renderer before any paint: Streamdown runs `preprocess` + `marked` lex over the whole string synchronously in a useMemo, an uninterruptible long task that no try/catch or content-visibility can help (our JS runs before the browser ever skips layout). Tiered fix: - Message gate: past 200KB, bypass markdown entirely and render the raw text in `content-visibility:auto` line-chunks — synchronous work is bounded to a string split, the browser virtualizes layout natively, and every line stays in the DOM (selectable, find-in-page). - Code-block budget: past 3k lines / 150KB, skip Shiki (which emits a span per token) and render plain, chunked the same way. - Collapse/expand: a reusable ExpandableBlock clamps code blocks and the huge-text fallback to a 120px preview with a gradient + chevron, expanding to 300px. The inner element is always a scroll container so the content-visibility chunks stay lazily laid out in both states. No content is ever dropped; the copy button (card header) always yields the full block. --- .../assistant-ui/directive-text.tsx | 35 +++--- .../components/assistant-ui/markdown-text.tsx | 41 +++++-- .../desktop/src/components/chat/code-card.tsx | 2 +- .../src/components/chat/expandable-block.tsx | 52 +++++++++ .../components/chat/shiki-highlighter.test.ts | 37 ++++++ .../src/components/chat/shiki-highlighter.tsx | 110 +++++++++++++++--- apps/desktop/src/lib/markdown-preprocess.ts | 4 - 7 files changed, 233 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/components/chat/expandable-block.tsx create mode 100644 apps/desktop/src/components/chat/shiki-highlighter.test.ts diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 21059e897ba..097b106281e 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -322,28 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string { return tail || id } +function safeEmbeddedImages(text: string) { + try { + return extractEmbeddedImages(text) + } catch { + return { cleanedText: text, images: [] as string[] } + } +} + +function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] { + try { + return [...hermesDirectiveFormatter.parse(text)] + } catch { + return [{ kind: 'text', text }] + } +} + /** * Renders text containing Hermes directives (`@file:...`, `@image:...`) as * inline chips. Embedded MEDIA images render below as a thumbnail row. */ export function DirectiveContent({ text }: { text: string }) { - // Both passes run text through regexes; on pathological input they can throw - // (or overflow) and, since this renders inside a useMemo under the message, - // bubble up to the root error boundary. Degrade gracefully to plain text. - const { cleanedText, images } = useMemo(() => { - try { - return extractEmbeddedImages(text ?? '') - } catch { - return { cleanedText: text ?? '', images: [] } - } - }, [text]) - const segments = useMemo(() => { - try { - return hermesDirectiveFormatter.parse(cleanedText) - } catch { - return [{ kind: 'text', text: cleanedText }] as Unstable_DirectiveSegment[] - } - }, [cleanedText]) + const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text]) + const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText]) return ( diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index d722e221215..b0429e88a92 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -19,8 +19,9 @@ import { useState } from 'react' +import { ExpandableBlock } from '@/components/chat/expandable-block' import { PreviewAttachment } from '@/components/chat/preview-attachment' -import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' +import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { ZoomableImage } from '@/components/chat/zoomable-image' import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' import { createMemoizedMathPlugin } from '@/lib/katex-memo' @@ -57,11 +58,6 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) // flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay // module-scope so the prop identity is stable across renders. function preprocessWithTailRepair(text: string): string { - // Streamdown runs `preprocess` inside its own useMemo, so anything thrown - // here escapes to the ROOT error boundary and takes down the whole app — a - // single adversarial message (e.g. content that overflows a regex/stack) - // shouldn't be able to do that. Degrade to the raw text instead; it still - // renders, just without our cosmetic normalization. try { return tailBoundedRemend(preprocessMarkdown(text)) } catch { @@ -462,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn( '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)' ) +const MAX_MARKDOWN_CHARS = 200_000 + +function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) { + const chunks = useMemo(() => chunkByLines(text, 200), [text]) + + return ( +
+ + {chunks.map((chunk, index) => ( +
+ {chunk.text} +
+ ))} +
+
+ ) +} + function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) { - const { status } = useMessagePartText() + const { status, text } = useMessagePartText() const isStreaming = status.type === 'running' // Keep code parsing enabled while streaming so incomplete fenced blocks still @@ -542,6 +565,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex [isStreaming] ) + if (text.length > MAX_MARKDOWN_CHARS) { + return + } + return ( ) { return (
(null) + const [expanded, setExpanded] = useState(false) + const [overflowing, setOverflowing] = useState(false) + + useLayoutEffect(() => { + const el = innerRef.current + + if (!el) {return} + + const measure = () => setOverflowing(el.scrollHeight > 121) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + + return () => observer.disconnect() + }, []) + + return ( +
+
+ {children} +
+ {overflowing && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/components/chat/shiki-highlighter.test.ts b/apps/desktop/src/components/chat/shiki-highlighter.test.ts new file mode 100644 index 00000000000..da97287c18e --- /dev/null +++ b/apps/desktop/src/components/chat/shiki-highlighter.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter' + +describe('exceedsHighlightBudget', () => { + it('highlights normal-sized blocks', () => { + expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false) + }) + + it('skips highlighting past the line budget', () => { + expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true) + }) + + it('skips highlighting past the char budget on few lines', () => { + expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true) + }) + + it('short-circuits on char budget before line loop', () => { + expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true) + }) +}) + +describe('chunkByLines', () => { + it('keeps a small block as a single chunk', () => { + const code = 'a\nb\nc' + expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }]) + }) + + it('splits a large block and reconstructs it losslessly', () => { + const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n') + const chunks = chunkByLines(code, 200) + + expect(chunks).toHaveLength(5) + expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code) + expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000) + }) +}) diff --git a/apps/desktop/src/components/chat/shiki-highlighter.tsx b/apps/desktop/src/components/chat/shiki-highlighter.tsx index 4993b993bf6..5a047a62657 100644 --- a/apps/desktop/src/components/chat/shiki-highlighter.tsx +++ b/apps/desktop/src/components/chat/shiki-highlighter.tsx @@ -1,7 +1,7 @@ 'use client' import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown' -import type { FC } from 'react' +import { type FC, useMemo } from 'react' import ShikiHighlighter from 'react-shiki' import { @@ -12,6 +12,7 @@ import { CodeCardSubtitle, CodeCardTitle } from '@/components/chat/code-card' +import { ExpandableBlock } from '@/components/chat/expandable-block' import { CopyButton } from '@/components/ui/copy-button' import { useI18n } from '@/i18n' import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' @@ -43,6 +44,74 @@ const SHIKI_COLOR_REPLACEMENTS: Record> = { 'github-light-default': { '#6e7781': '#57606a' } } +const MAX_HIGHLIGHT_CHARS = 150_000 +const MAX_HIGHLIGHT_LINES = 3_000 +const CHUNK_LINES = 200 +const EST_LINE_PX = 16 + +export function exceedsHighlightBudget(code: string): boolean { + if (code.length > MAX_HIGHLIGHT_CHARS) { + return true + } + + let lines = 1 + let idx = code.indexOf('\n') + + while (idx !== -1) { + if ((lines += 1) > MAX_HIGHLIGHT_LINES) { + return true + } + + idx = code.indexOf('\n', idx + 1) + } + + return false +} + +interface CodeChunk { + text: string + lines: number +} + +export function chunkByLines(code: string, perChunk: number): CodeChunk[] { + const lines = code.split('\n') + + if (lines.length <= perChunk) { + return [{ text: code, lines: lines.length }] + } + + const chunks: CodeChunk[] = [] + + for (let i = 0; i < lines.length; i += perChunk) { + const slice = lines.slice(i, i + perChunk) + chunks.push({ text: slice.join('\n'), lines: slice.length }) + } + + return chunks +} + +const PlainCode: FC<{ code: string }> = ({ code }) => { + const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code]) + + if (chunks.length === 1) { + return {code} + } + + return ( + <> + {chunks.map((chunk, index) => ( + + {chunk.text} + + ))} + + ) +} + export const SyntaxHighlighter: FC = ({ components: { Pre }, language, @@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC = ({ const cleanLanguage = sanitizeLanguageTag(language || '') const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : '' + const plain = defer || exceedsHighlightBudget(trimmed) return ( @@ -83,24 +153,26 @@ export const SyntaxHighlighter: FC = ({ /> -
-          {defer ? (
-            {trimmed}
-          ) : (
-            
-              {trimmed}
-            
-          )}
-        
+ +
+            {plain ? (
+              
+            ) : (
+              
+                {trimmed}
+              
+            )}
+          
+
) diff --git a/apps/desktop/src/lib/markdown-preprocess.ts b/apps/desktop/src/lib/markdown-preprocess.ts index 4fc61e48e00..5fd08453b26 100644 --- a/apps/desktop/src/lib/markdown-preprocess.ts +++ b/apps/desktop/src/lib/markdown-preprocess.ts @@ -151,10 +151,6 @@ function normalizeVisibleProse(text: string): string { .join('') } -// `out.push(...lines)` spreads every element as a separate call argument, so a -// single fenced block with tens of thousands of lines (a logged minified -// bundle, base64 blob, huge tool dump) overflows V8's argument-count limit and -// throws `RangeError: Maximum call stack size exceeded`. Append iteratively. function extend(out: string[], lines: string[]) { for (const line of lines) { out.push(line)