diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index b870913b012..21059e897ba 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -327,8 +327,23 @@ function shortLabel(type: HermesRefType, id: string): string { * 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]) + // 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]) return ( diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 2c87f6d0c33..d722e221215 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -57,7 +57,16 @@ 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)) + // 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 { + return text + } } // Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full