Skip to content

Render Mermaid diagrams in chat replies - #760

Merged
wingleeio merged 4 commits into
zeronsh:mainfrom
jsgrrchg:zeron/mermaid-diagrams-chat-integration
Oct 3, 2026
Merged

wingleeio merged 4 commits into
zeronsh:mainfrom
jsgrrchg:zeron/mermaid-diagrams-chat-integration

Conversation

@jsgrrchg

@jsgrrchg jsgrrchg commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The file preview already draws ```mermaid fences with the native mermaid-rs-renderer engine, but chat did not: when an agent replied with a diagram, the transcript showed only the source. The renderer had a hook for this (MediaUi.diagram), and the transcript passed media: None.

While measuring the chat integration, I also found that every prepared SVG and Mermaid diagram was charged against the media budget at the largest raster any view could ask for. This applied to the file preview as well. As a result, the file preview stopped drawing diagrams after about eight per document.

Change

Mermaid in chat

  • Assistant replies render Mermaid fences as diagrams. They use the same engine, fence frame, Show source / Show diagram toggle, Copy action, and lightbox as the file preview.
  • Until a diagram is ready, the fence keeps its ordinary source. Completion therefore changes the row height at most once, with no "Rendering…" placeholder in between.
  • A failed render, or a diagram type the engine does not support, keeps the source. A warning marker in the fence header shows the engine's diagnostic as a tooltip.
  • The source toggle follows the stable row identity, so it survives the streaming → complete flip.
  • Inline images in chat keep their existing text rendering. MediaUi.image is now optional, so this change does not alter them.

Streaming safety

A fence that may still be growing is never rendered:

  • A block requests a diagram only when a later row of the same reply follows it, or when the reply is complete.
  • The streaming tail keeps its source, so per-token commits start no render work. A test feeds a fence token by token to cover this.
  • Whether a block is settled comes from the transcript's row list, not from the row's own parse tree. A row whose bytes did not change between commits keeps its old tree, where it may still look like the tail.

Scroll and runway

When a diagram replaces its source, only the rows painting that diagram are remeasured. The change then raises the same layout signals as other row-height changes: the viewport layout revision, wake_spring when the transcript is pinned, and own_turn_kick when an own turn is active. As a result:

  • The bottom pin follows the swap.
  • The own-turn runway reservation absorbs the change in the same layout, without moving the sent prompt.

Media accounting fix (file preview and chat)

decode_image reserved a 900×480 raster at 4× density for every SVG, so a 177×348 class diagram counted as 7.6 MiB. Media now counts its source plus the CPU and GPU copies of the raster it actually holds.

Admission no longer reserves headroom. Instead, the file preview's resize_media and the chat cache's set_view re-check the budget before re-rasterizing at a higher width or density (MediaImage::preview_within). A larger variant that does not fit keeps the current raster: slightly softer, never over budget.

Screenshot

Mermaid flowchart rendered directly in an assistant reply in the running Linux app.

Mermaid diagram rendered in Zeron chat

Performance

All numbers below come from a temporary release-mode harness. It is not included in the PR. It used the six fixtures in scripts/fixtures/markdown-preview/, on Linux, with 10 iterations per diagram.

UI thread: effectively zero added cost

Per-frame operation Cost
Cache lookup per visible fence, diagram ready ~130 ns
Cache lookup, 6.5 KB source still pending (hashes the source) ~1.4 µs
Per-frame bookkeeping (begin_frame + unchanged set_view) ~14 ns
Re-rasterize 6 retained diagrams after a width change ~22 µs, once

Even with five diagrams on screen at 60 fps, this stays below 1 µs of a 16.6 ms frame. Rows without Mermaid only clone one shared Rc handler, which is built once per transcript rather than once per frame.

Streaming adds no work: the tail fence is never rendered.

Off the UI thread: one-time work per diagram

Rows request fences while they lay out, so only painted diagrams cost anything. One serialized loop per transcript renders them on the background executor. Between renders it picks the next source and drops requests whose rows have scrolled away, rather than queueing them. GPUI loads the resulting image asset on its background executor too, so neither step blocks a frame.

Fixture Lines Generate SVG Prepare SVG Rasterize at 2×
flowchart 9 34.6 ms 1.8 ms 16.3 ms
sequence 12 0.7 ms 2.5 ms 30.6 ms
class 10 0.3 ms 1.2 ms 6.4 ms
state 7 1.5 ms 0.9 ms 5.7 ms
er 11 3.3 ms 1.3 ms 9.8 ms
gantt 7 0.1 ms 1.4 ms 10.9 ms

The source stays visible for these few milliseconds, and the diagram then replaces it.

Memory: retention budget and accounting fix

The chat keeps diagrams under a 64 MiB / 64-entry budget, evicting the least recently painted first. Diagrams painted in the latest two passes are never evicted, and an evicted diagram simply renders again when its row returns. A theme change discards every diagram and rejects results that were computed under the old theme.

The accounting fix (second commit), measured before and after with the same harness:

Metric Before After Change
Accounted memory per diagram (chat column) 7.71 MiB 3.78 MiB −51%
Accounted memory per diagram (file preview column) 7.71 MiB 3.93 MiB −49%
Over-accounting vs. actual raster memory (3.67 MiB) +110% +3%
File preview: diagrams drawn in a 20-diagram document 8 15 +87.5%
Chat: diagrams retained after scrolling through 40 8 16 +100%
Chat: background re-renders when scrolling back up 32 24 −25%
Lightbox with 8 diagrams loaded: free budget 1.45 MiB 27.12 MiB +1770%
Lightbox: diagrams that get an enlarged raster 0 / 8 4 / 8
Lightbox: average raster pixels 588 k 778 k +32%

In the "after" row, the other 4 lightbox diagrams are small and already shown at full resolution in the column, so there is no larger raster to make. Per-frame cost is unchanged: the budget sum is only recomputed when the width or display density changes.

These are synthetic measurements. Frame timing in the running app (ZERON_FRAME_STATS=1) has not been captured yet.

Implementation notes

  • markdown/mermaid_cache.rs (new) holds the cache: requests scoped to paint passes, stale-request dropping, LRU eviction, theme invalidation, raster view tracking, and source toggles.
  • markdown/render.rs: the diagram handler returns DiagramView::{Source, Failed(reason), Diagram(DiagramUi)}. CodeBlockTooltip takes a SharedString so the failure tooltip can show a dynamic diagnostic.
  • image_media::preview_element is the clickable centered media element, now shared by the file preview and chat. MediaImage::preview_within is the budget-aware re-raster.
  • transcript.rs:
    • the media handler;
    • the worker loop (ensure_diagram_worker / finish_diagram);
    • diagram_layout_changed;
    • the diagram lightbox. It reuses attachment_preview through lightbox_with_size with the diagram's natural size, and releases its enlarged raster on close.
  • The file preview's behaviour is unchanged apart from the accounting fix.
  • docs/markdown-preview.md gains a "Mermaid in chat" section, along with the updated limits and validation notes.

Testing

  • cargo test -p zeron-ui --lib -- --test-threads=1: 1529/1529 passed.
  • In one parallel run, markdown_drag_tracks_each_table_column_and_wrapped_cell and rendered_truncation_resizes_and_selects_the_original_url failed. Both pass alone and in the sequential run; it is the same parallel-only flakiness as in earlier PRs.
  • cargo check -p zeron passes. Rustfmt is clean for the changed files, and git diff --check passes. Clippy reports no new lint kinds; the warnings it shows on changed lines are the same type_complexity and collapsible_if patterns as the original code.
  • New tests:
    • Cache unit tests: one request per source and row tracking, dropping requests that scrolled away, theme invalidation of retained and in-flight results, eviction that spares recently painted diagrams, and source toggles following their rows.
    • Transcript: a fence streamed token by token starts no render while it is the tail. The following block renders it exactly once. The row height changes, the toggle restores the source, and completion keeps the diagram and the toggle.
    • Transcript: a pinned, overflowing stream stays at the bottom through the swap and through further streaming.
    • Transcript: rendering a completed tail neither moves the own-turn prompt nor opens blank space below the runway. The diagram lightbox opens and releases its raster.
    • Media: accounting follows the current raster, and a larger re-raster happens only within the budget.
  • Native visual verification on Linux or macOS has not been done.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Assistant replies now draw ```mermaid fences as diagrams with the file
preview's engine, fence frame, source toggle, Copy action and lightbox.
Inline images in chat keep their text rendering.

Streaming never renders a fence that may still be growing: only blocks a
later row of the same reply follows, or blocks of a completed reply,
request a diagram, so per-token commits start no render work. Until a
diagram is ready the fence shows its source, and a failed render keeps
the source with the engine's diagnostic in a header marker.

Rows request their fences while laying out, so only painted diagrams
are rendered, one at a time off the UI thread; requests whose rows
scrolled away are dropped. Results are retained under a byte budget,
least recently painted first out. A swap remeasures only the rows
painting it and uses the existing layout signals, so the bottom pin
follows and the own-turn runway absorbs the change without moving the
sent prompt.
decode_image reserved the largest preview any view could request (900x480
at 4x density) for every SVG and Mermaid diagram, so a 177x348 class
diagram counted 7.6 MiB against the 64 MiB media budgets. The file
preview stopped drawing diagrams after about eight per document, the chat
cache retained about eight, and the lightbox had little room left to
enlarge.

Media now counts its source plus the CPU and GPU copies of the raster it
holds. Since admission no longer reserves headroom, the file preview and
the chat diagram cache re-check their budget before a larger re-raster
(MediaImage::preview_within); a variant that does not fit keeps the
current raster.
@jsgrrchg
jsgrrchg marked this pull request as draft October 3, 2026 02:20
Render diagrams on a transparent canvas so they sit on the fence body,
with rounded hairline cards for nodes, muted labels and connectors, and a
soft accent tint on default decisions, sequence notes and activations.
Gantt bars take their hues from the accent and gridlines use the theme
border. Explicit style and classDef colors are kept. Tighter spacing keeps
labels larger once a diagram is scaled to the reading column, and the
lightbox draws diagrams on a plate in the fence body color.
@jsgrrchg
jsgrrchg marked this pull request as ready for review October 3, 2026 03:28
ready_count and ready_media serve the transcript tests, which only compile
on Linux, so they were dead code (with a warning) on macOS and Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@wingleeio wingleeio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security-audited and reviewed. Pushed one small fix: gated the Linux-only MermaidCache test helpers so they don't warn as dead code on macOS/Windows. Thanks!

@wingleeio
wingleeio merged commit 9e1a111 into zeronsh:main Oct 3, 2026
14 checks passed
@jsgrrchg
jsgrrchg deleted the zeron/mermaid-diagrams-chat-integration branch October 3, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants