Files
hermes-agent/apps/desktop/src/components/assistant-ui/markdown-text.tsx
T
Brooklyn Nicholson 9d31577590 Tighten conversation rhythm, flatten the tool list, and smooth streaming text
Conversation rhythm:
- Single `--paragraph-gap` knob drives paragraph spacing both inside a
  markdown block and between consecutive prose parts, out-specifying Tailwind
  Typography's prose margins. Code cards carry the same gap themselves so it
  holds at any Streamdown nesting depth.
- Two-tier vertical rhythm: `--turn-block-gap` separates scaffolding (tools /
  thinking) from the reply; `--tool-row-gap` keeps a tool run tight.
- Drop the prose indent so prose, tools, todos, and thinking share one left
  edge. `---` renders as quiet spacing, not a heavy rule.

Flat tool list:
- Tools always render as a standalone-row stack, never a "Tool actions · N
  steps" group. assistant-ui slices the tool range unstably (interleaved live
  vs. reconstructed-consecutive when settled), so grouping reshuffled the whole
  turn the instant it settled. Flat rows are pixel-identical either way.
- Inline approvals can no longer be buried in a collapsed group body.
- Remove the now-dead grouping helpers from tool-fallback-model.

Empty thinking:
- Suppress reasoning disclosures with no visible text (encrypted / spinner-
  coerced reasoning) instead of leaving an empty "Thinking" header.
- Tail stall indicator returns "thinking" when a running turn goes quiet.

Streaming cadence:
- Smooth character-reveal decouples visible cadence from bursty arrival.
- Flush queued text deltas before applying tool events so a tool row can't
  jump ahead of its preceding text.
- Disable Nagle on the GUI WebSocket so per-token frames aren't coalesced.

Polish: clarify/patch/vision_analyze tool meta, queue-panel + diff-lines
spacing, sticky human bubble expands on focus (not hover).
2026-06-06 10:45:31 -05:00

498 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import {
type StreamdownTextComponents,
StreamdownTextPrimitive,
type SyntaxHighlighterProps
} from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { 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'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
filePathFromMediaPath,
mediaExternalUrl,
mediaKind,
mediaName,
mediaPathFromMarkdownHref,
mediaStreamUrl
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
// Math rendering plugin (KaTeX). Configured once at module scope — the
// plugin is stateless beyond its internal cache so re-creating per-render
// would needlessly thrash. We use a memoizing wrapper around rehype-katex
// (see lib/katex-memo.ts) so that during streaming we re-katex only the
// equations whose source actually changed since the last token. With the
// stock @streamdown/math plugin every equation re-renders on every token,
// which throttles UI updates badly for math-heavy responses; the memoized
// plugin keeps the steady-state work proportional to "new equations
// arriving" rather than "equations × tokens-per-second".
//
// `singleDollarTextMath: true` enables `$x^2$` for inline math (de-facto
// LLM convention). The default false-setting only accepts `$$...$$`.
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
}
// Stream audio/video through the custom protocol: data URLs are capped and
// load the whole file into memory, which broke playback for larger videos.
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
return mediaStreamUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
return (
<button
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
type="button"
>
Open {kind} file
</button>
)
}
function MediaAttachment({ path }: { path: string }) {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
const kind = mediaKind(path)
const name = mediaName(path)
useEffect(() => {
let cancelled = false
let objectUrl = ''
setFailed(false)
setSrc('')
void mediaSrc(path)
.then(value => {
if (value.startsWith('blob:')) {
objectUrl = value
}
if (!cancelled) {
setSrc(value)
} else if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return () => {
cancelled = true
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [path])
if (kind === 'image' && src) {
return (
<span className="block">
<MarkdownImage alt={name} src={src} />
</span>
)
}
if (kind === 'audio' && src) {
return (
<span className="my-3 block max-w-md rounded-xl border border-border bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
{failed && <OpenMediaButton kind="audio" path={path} />}
</span>
)
}
if (kind === 'video' && src) {
return (
<span className="my-3 block max-w-2xl rounded-xl border border-border bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<video
className="block max-h-112 w-full rounded-lg bg-black"
controls
onError={() => setFailed(true)}
src={src}
/>
{failed && <OpenMediaButton kind="video" path={path} />}
</span>
)
}
return (
<a
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
openExternalLink(mediaExternalUrl(path))
}}
>
{failed ? `Open ${name}` : `Loading ${name}...`}
</a>
)
}
function childrenToText(children: unknown): string {
if (typeof children === 'string' || typeof children === 'number') {
return String(children).trim()
}
if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) {
return children.join('').trim()
}
return ''
}
function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a'>) {
const mediaPath = mediaPathFromMarkdownHref(href)
if (mediaPath) {
return <MediaAttachment path={mediaPath} />
}
const previewTarget = previewTargetFromMarkdownHref(href)
if (previewTarget) {
return <PreviewAttachment source="explicit-link" target={previewTarget} />
}
const target = href ? normalizeExternalUrl(href) : href
if (!target || !/^https?:\/\//i.test(target)) {
return (
<a
className={cn(
'font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere',
className
)}
href={href}
rel="noopener noreferrer"
target="_blank"
{...props}
>
{children}
</a>
)
}
const text = childrenToText(children)
const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined
return (
<PrettyLink className={cn('wrap-anywhere', className)} fallbackLabel={fallbackLabel} href={target} {...props} />
)
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
return (
<ZoomableImage
alt={alt}
className={cn(
'm-0 block h-auto w-auto max-h-(--image-preview-height) max-w-[min(100%,var(--image-preview-max-width))] rounded-lg object-contain shadow-[0_0.0625rem_0.125rem_color-mix(in_srgb,#000_4%,transparent),0_0.625rem_1.5rem_color-mix(in_srgb,#000_5%,transparent)]',
className
)}
containerClassName="my-2 block w-fit max-w-full"
slot="aui_markdown-image"
src={src}
{...props}
/>
)
}
// Steady character-reveal for streaming text: decouples visible cadence from
// bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth,
// reimplemented for a tunable rate). Proportional drain — each frame reveals a
// slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever
// the size; the per-frame cap stops a huge dump rendering as one slab. The loop
// is gated on backlog, not isRunning, so a stream that completes mid-reveal
// keeps draining its tail instead of snapping.
const REVEAL_DRAIN_MS = 500
const REVEAL_MAX_CHARS_PER_FRAME = 30
function useSmoothReveal(text: string, isRunning: boolean): string {
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
const targetRef = useRef(text)
const shownRef = useRef(displayed)
const frameRef = useRef<number | null>(null)
const lastTickRef = useRef(0)
shownRef.current = displayed
targetRef.current = text
useEffect(() => {
if (typeof window === 'undefined') {
return
}
// Non-extending change (regenerate / branch / history swap): restart from
// empty while streaming, else snap to the replacement.
if (!text.startsWith(shownRef.current)) {
shownRef.current = isRunning ? '' : text
setDisplayed(shownRef.current)
}
if (shownRef.current.length >= text.length || frameRef.current !== null) {
return
}
lastTickRef.current = performance.now()
const tick = () => {
const now = performance.now()
const dt = now - lastTickRef.current
lastTickRef.current = now
const remaining = targetRef.current.length - shownRef.current.length
const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)))
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
setDisplayed(shownRef.current)
frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null
}
frameRef.current = requestAnimationFrame(tick)
}, [text, isRunning])
useEffect(
() => () => {
if (frameRef.current !== null && typeof window !== 'undefined') {
cancelAnimationFrame(frameRef.current)
}
},
[]
)
return displayed
}
// Re-publish the part context with a smooth character-reveal, above
// DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status
// stays running while revealing so the caret persists past the underlying part
// settling.
function SmoothStreamingText({ children }: { children: ReactNode }) {
const { text, status } = useMessagePartText()
const isRunning = status.type === 'running'
const revealed = useSmoothReveal(text, isRunning)
return (
<TextMessagePartProvider isRunning={isRunning || revealed !== text} text={revealed}>
{children}
</TextMessagePartProvider>
)
}
/**
* Re-publish the active message-part context with React's `useDeferredValue`
* applied to the streaming text and status. The outer wrapper still re-renders
* on every token, but the work it does is trivial (one hook, one provider).
*
* The expensive subtree (Streamdown → micromark → mdast → hast → React) lives
* inside `<TextMessagePartProvider>` and reads the deferred text via the
* normal `useMessagePartText` hook. React's concurrent scheduler then has
* permission to:
* - skip intermediate token states when the next token arrives mid-render
* (it abandons the in-flight deferred render and starts over)
* - deprioritize the markdown render when the main thread is busy with an
* urgent task (typing, scrolling, layout work elsewhere)
*
* Net effect: per-token CPU is unchanged but the *blocking* part of that work
* goes away — typing-while-streaming stays a single-frame paint, scroll
* stutter disappears, and the longtask histogram tightens because long
* commits can be interrupted and discarded.
*
* Industry standard (Streamdown's own block-array setState already uses
* `useTransition`); this just lifts the deferral up to the consumer text
* boundary so it covers the whole pipeline, not just the inner setState.
*/
function DeferStreamingText({ children }: { children: ReactNode }) {
const { text, status } = useMessagePartText()
const deferredText = useDeferredValue(text)
const isRunning = status.type === 'running'
return (
<TextMessagePartProvider isRunning={isRunning} text={deferredText}>
{children}
</TextMessagePartProvider>
)
}
interface MarkdownTextSurfaceProps {
containerClassName?: string
containerProps?: ComponentProps<'div'>
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
// table-driven so adding/tweaking levels is one row.
const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
h1: 'text-[1rem] tracking-tight',
h2: 'text-[0.9375rem] tracking-tight',
h3: 'text-[0.875rem]',
h4: 'text-[0.8125rem]'
}
const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
'prose-headings:text-foreground prose-strong:text-foreground',
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-muted-foreground/70',
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
)
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status } = useMessagePartText()
const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still
// render as code cards. The expensive Shiki pass is deferred by
// `SyntaxHighlighter` below when `isStreaming` is true.
const plugins = useMemo(() => ({ math: mathPlugin, code }), [])
const components = useMemo(
() =>
({
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
<h1 className={cn('my-1 font-semibold', HEADING_SIZES.h1, className)} {...props} />
),
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
<h2 className={cn('my-1 font-semibold', HEADING_SIZES.h2, className)} {...props} />
),
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
<h3 className={cn('my-1 font-semibold', HEADING_SIZES.h3, className)} {...props} />
),
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
),
p: ({ className, ...props }: ComponentProps<'p'>) => (
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
// must out-specify Tailwind Typography's `prose` margins — so no
// `my-*` here on purpose.
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
),
a: MarkdownLink,
// `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
<table
className={cn(
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
className
)}
{...props}
/>
</div>
),
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
<thead className={cn('m-0 bg-muted/35 text-muted-foreground', className)} {...props} />
),
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
className
)}
{...props}
/>
),
td: ({ className, ...props }: ComponentProps<'td'>) => (
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />
}) as StreamdownTextComponents,
[isStreaming]
)
return (
<StreamdownTextPrimitive
components={components}
containerClassName={cn(MARKDOWN_CONTAINER_CLASS_NAME, containerClassName)}
containerProps={containerProps}
lineNumbers={false}
mode="streaming"
// Always auto-close incomplete fences — even during streaming.
// Without this, an unclosed ```python ... ``` whose body contains
// `$` (very common: shell snippets, JS template strings, dollar
// amounts) leaks those dollars out to the math parser and they
// get rendered as broken inline math until the closing fence
// arrives. Shiki is independently deferred via `defer={isStreaming}`
// on the SyntaxHighlighter component, so we don't pay code-block
// tokenization on every token even with this set.
parseIncompleteMarkdown
plugins={plugins}
preprocess={preprocessMarkdown}
/>
)
}
interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
isRunning: boolean
text: string
}
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
return (
<TextMessagePartProvider isRunning={isRunning} text={text}>
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
</SmoothStreamingText>
</TextMessagePartProvider>
)
}
const MarkdownTextImpl = () => {
return (
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
</SmoothStreamingText>
)
}
export const MarkdownText = memo(MarkdownTextImpl)