Render replies with HyperMarkdown, including reasoning content

EC
1mo ago
+3209-1489

Splits part of #1084 into a focused PR, as requested there. This replaces #1103 and #1104, which are folded into one after discussion: HyperMarkdown already renders reasoning blocks natively, so the renderer migration and the reasoning work land more naturally together than as two stacked PRs.

Depends on #1101 (streaming render performance). The branch is based on that branch, so this diff currently includes its commits as well; it shrinks to these once #1101 merges. The renderer reuses the shared syntax-highlight options introduced there.

1. Render replies with HyperMarkdown

Replace react-markdown with @aeven-ai/hypermarkdown, which parses only the block that is still changing and caches the blocks already settled, so a long answer is no longer re-parsed from the top on every update. Answers are handed over as deltas (stream-delta.mjs) and finalized once the stream ends.

react / react-dom move from @preact/compat@^17.1.2 to ^18.3.2 so packages that declare a React 18 peer range can be installed. The alias is a thin re-export of preact/compat, so the runtime implementation is still preact@10.22.1 — this changes the reported version, not the rendering.

Removed as redundant:

  • markdown-without-katex.jsx, a 200-line near-duplicate of the renderer. The KaTeX-free build now swaps math-plugin.mjs for math-plugin-without-katex.mjs and mykatex.min.css for mykatex-without-katex.css through the NormalModuleReplacementPlugin hook that already existed for this purpose.
  • Pre.jsx, the custom code-block wrapper, now that the renderer has its own toolbar, and change-children-font-size.mjs, orphaned by that removal.
  • react-markdown, remark-breaks, rehype-raw, github-markdown-css (never imported; its CSS was vendored into styles.scss), parse5, and the parse5 webpack alias, which forced every parse5 import to the root v6 and broke hast-util-raw v9.

2. Show reasoning content from reasoning models

The thinking block is decided by the API field alone: delta.reasoning_content (DeepSeek R1), delta.reasoning (OpenRouter-style), a whole non-streaming message.*, or Anthropic's thinking_delta (Anthropic previously ignored it, so that text was lost). The text is streamed to the card on its own {reasoning} channel and is never merged into the answer.

Nothing is read out of the content, and nothing is read into it. The content channel is the answer, verbatim, so a <think> that a reader typed or a model wrote stays ordinary text — it can neither open, close nor reshape a block — and the markdown parser only ever sees the body of the thinking block. inline-reasoning.mjs and reasoning-content.mjs are gone with the content parsing they existed for.

The renderer grows a ReasoningPanel that owns its own collapsible block and its own HyperMarkdown instance, so no tag in the answer can affect it. It reuses the renderer's .reasoning-* markup, which keeps hypermarkdown.css working unchanged; one additive rule lets the nested renderer inherit the block's small, muted type. The panel behaves like the native block: open while the thinking arrives, collapsing with a duration once it stops, re-openable afterwards.

pushRecord() still stores only the answer text, so thinking never re-enters the conversation context on the next turn, which is what DeepSeek does with reasoning_content too. A turn that reasoned but never answered is not recorded at all.

Sharing the pipeline also collapses the duplicated transport code: every endpoint reports errors through createApiResponseError() and decodes SSE through parseJsonMessage(), and Azure OpenAI reuses the shared core instead of carrying its own copy of the stream parser.

Smaller details:

  • Reasoning-only chunks no longer repost an unchanged answer.
  • A retry, a model switch, or an error replacement clears the previous thinking.
  • Retries discard buffered answer frames (from #1101) so the previous attempt cannot be re-rendered.
  • List markers are drawn by the browser (list-markers.mjs): bullets nested in a numbered list stay bullets, and a list that resumes at another number keeps its start.
  • A thrown error is stored as its message, since the renderer takes text and would otherwise break on an Error object.

Requested in #839 and #892; related to #848, #833.

Localization

Thinking Content and Thought for {seconds}s are added to every registered locale; they previously existed only in English and the two Chinese locales, so the others fell back to English.

Styling impact (measured)

Compiled Chromium content-script.css, master vs this branch, diffed rule by rule:

  • 0 existing selectors removed, 0 declarations changed — the vendored .markdown-body theme (headings, paragraphs, links, quotes, tables, code) is untouched.
  • +287 selectors, all additive: 261 renderer (hypermarkdown) + 17 code-block/tooltip + 8 list-marker overrides + 1 other.
  • Size 650,075 → 695,250 bytes (+6.9%). The KaTeX-free variant goes 72,845 → 118,020 bytes, still with 0 @font-face; the full build keeps its 26 KaTeX faces.

What actually looks different: code blocks get the renderer's theme and toolbar (copy; fullscreen and preview are off because both expect the host to hide its own chrome), tables get the renderer's toolbar, and the thinking block is drawn with the renderer's collapsible .reasoning-* chrome instead of the old inline-styled card.

Known behavior changes worth a note in the release:

  • Single newlines no longer become <br> — remark-breaks has no equivalent in the new renderer. ChatGPT behaves the same way, but this is visible for anyone who relied on it.
  • The per-code-block font-size selector is gone with Pre.jsx; the renderer has no equivalent.

Tests

  • tests/unit/components/stream-delta.test.mjs, list-markers.test.mjs — the delta contract and list start cleanup.
  • tests/unit/services/apis/reasoning-content.test.mjs — reasoning comes from the field, stays out of the answer, and is not sent back in context.
  • tests/unit/components/special-tags.test.mjs — <think> in prose and in code stays literal.
  • tests/unit/components/markdown-render.test.mjs — the DOM contract: a tag in the answer, a tag in a code fence, a tag inside the thinking, markdown inside the block, and highlight parity.
  • tests/unit/services/apis/claude-api.test.mjs — thinking_delta streams as reasoning, never as the answer.
  • tests/unit/components/conversation-card-lifecycle.test.mjs — buffered chunks, empty answer snapshots, coalesced reasoning, a reasoning-only completion, an error closing the trailing answer, and a dropped transport flushing the buffer.
  • tests/unit/services/apis/custom-api.test.mjs — updated for "an empty delta.content does not repost the answer".

Validation

  • npm test — 1151 passing.
  • npm run lint — clean.
  • npm run build — all four variants; build/chromium/ has the expected artifacts.
  • npm ci installs cleanly. Note: the lockfile the original branch carried was missing the optional dependencies of less (errno, needle, probe-image-size, …), which npm 11 rejects as out of sync; the lockfile here is reconciled with npm install.

Validation skipped: manual browser testing — no browser automation is available here. A smoke test would stream a reasoning-model answer with a code block and a table, check the thinking block streams expanded and settles on its real duration, and confirm the minimal build's styling.

Summary by CodeRabbit

  • New Features
    • Markdown responses now support improved code highlighting, tables, math, links, and list formatting.
    • AI reasoning appears in a separate, collapsible panel with elapsed-time information.
    • Thinking-content labels and elapsed-time messages are available in multiple languages.
  • Improvements
    • Streaming answers and reasoning display more smoothly, including during retries and interrupted connections.
    • Responses from supported AI services now handle reasoning updates and errors more consistently.