diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index b870913b012..097b106281e 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -322,13 +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 }) { - const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text]) - const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [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.test.ts b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts index fad9944741f..b3ea416d066 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts @@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => { expect(output).toContain('') }) + + it('handles a fenced block larger than V8 spread-argument limit', () => { + // A single huge code block (e.g. a logged minified bundle) used to throw + // `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`. + const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n') + const input = `\`\`\`js\n${body}\n\`\`\`` + + expect(() => preprocessMarkdown(input)).not.toThrow() + }) }) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index c5ca96a3a2e..3da29aebbcc 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,7 +58,11 @@ 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 { - return tailBoundedRemend(preprocessMarkdown(text)) + try { + return tailBoundedRemend(preprocessMarkdown(text)) + } catch { + return text + } } // Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full @@ -453,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 @@ -551,6 +583,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 aea5af1b82c..5fd08453b26 100644 --- a/apps/desktop/src/lib/markdown-preprocess.ts +++ b/apps/desktop/src/lib/markdown-preprocess.ts @@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string { .join('') } +function extend(out: string[], lines: string[]) { + for (const line of lines) { + out.push(line) + } +} + function pushProseFence(out: string[], indent: string, info: string, lines: string[]) { if (info) { out.push(`${indent}${info}`.trimEnd()) } - out.push(...lines) + extend(out, lines) } function findClosingFence(lines: string[], start: number, marker: string): number { @@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string { } if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) { - out.push(...bodyLines) + extend(out, bodyLines) index = closeIndex + 1 continue @@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string { // any literal `$$` characters in the body don't collide with // an outer math wrapper. No close emitted yet — streaming. out.push(`${indent}${marker}math`) - out.push(...bodyLines) + extend(out, bodyLines) } else { out.push(`${indent}${marker}${language}`) - out.push(...bodyLines) + extend(out, bodyLines) } break @@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string { // colliding with our wrapper. Without this rewrite the block // would render as a syntax-highlighted "latex" code listing. out.push(`${indent}${marker}math`) - out.push(...bodyLines) + extend(out, bodyLines) out.push(`${indent}${marker}`) index = closeIndex + 1 @@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string { } out.push(`${indent}${marker}${language}`) - out.push(...bodyLines) + extend(out, bodyLines) out.push(`${indent}${marker}`) index = closeIndex + 1 }