hermes-agent/apps/desktop/src/components/chat/expandable-block.tsx
Brooklyn Nicholson 0138282f97 perf(desktop): keep oversized messages from freezing the chat
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.
2026-06-17 08:25:52 -05:00

53 lines
1.5 KiB
TypeScript

'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface ExpandableBlockProps {
children: ReactNode
className?: string
}
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const innerRef = useRef<HTMLDivElement>(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 (
<div className="relative">
<div
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
)}
</div>
)
}