Skip to content

Render replies with HyperMarkdown - #1103

Closed
ecokayiza wants to merge 2 commits into
ChatGPTBox-dev:masterfrom
ecokayiza:feat/hypermarkdown-compat
Closed

ecokayiza wants to merge 2 commits into
ChatGPTBox-dev:masterfrom
ecokayiza:feat/hypermarkdown-compat

Conversation

@ecokayiza

@ecokayiza ecokayiza commented Oct 3, 2026 •

Copy link
Copy Markdown

Splits part of #1084 into a focused PR, as requested there.

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

What this does

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. Every API the extension uses is still exported: unmountComponentAtNode, render, createPortal, findDOMNode, flushSync, unstable_batchedUpdates, and the hooks.

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. The minimal build ships 0 KaTeX font faces; the full build is unchanged.
  • Pre.jsx, the custom code-block wrapper, now that the renderer has its own toolbar. This drops the per-block font-size selector — the renderer has no equivalent. change-children-font-size.mjs is 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.

Other behavior details:

  • Fullscreen and the HTML preview are off for code blocks and tables: both expect the host to hide its own chrome around them, which this card does not do.
  • The loading placeholder is injected as HTML and styled through .gpt-loading, so allowedTags: { p: ['className'] } keeps that class.
  • The renderer's stylesheets are imported by the content-script entry so every variant ships them; otherwise they land in a chunk whose CSS is never packaged.
  • 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.

Addresses #892.

Tests

  • tests/unit/components/stream-delta.test.mjs — the delta contract (first write, growth, no-op, finalize).
  • tests/unit/components/list-markers.test.mjs — start="0" cleanup, real starts preserved.
  • Upstream's conversation-card-lifecycle.test.mjs and the selection-toolbar loader hooks are updated/stubbed for the new stylesheet imports.

Validation

  • npm test — 1111 passing.
  • npm run lint — clean.
  • npm run build — all four variants; build/chromium/ has the expected artifacts, and the minimal content-script.css carries 313 .hypermarkdown rules with 0 @font-face (the full build keeps its 26 KaTeX faces).
  • 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 check a streamed answer with a code block and a table, the loading placeholder, a Bing answer with <sup> citations, and the minimal build's styling.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Improved Markdown rendering for streamed answers, including smoother updates, code highlighting, math support, and corrected ordered-list numbering.
    • Added localized “Thought for {seconds}s” text in English, Simplified Chinese, and Traditional Chinese.
  • Bug Fixes
    • Improved error messages shown when answer requests fail.
    • Streamed answers now display their latest content reliably when completed or interrupted.

ecokayiza and others added 2 commits October 3, 2026 17:43
A streamed answer arrives as a series of cumulative snapshots and every
snapshot re-rendered the whole answer, so a long reply re-parsed from the top
on each chunk. Coalesce the chunks into one render per animation frame
(`answer-buffer.mjs`) and always flush the newest text before the answer is
finalized. Buffered frames are dropped when the card unmounts, is cleared, or
starts a new question, so a stale frame cannot resurrect an old answer. The
buffer falls back to a timer where the host has no animation frames (jsdom and
other headless renderers), which keeps the lifecycle tests working.

Syntax auto-detection is limited to a common-language subset in
`highlight-options.mjs`. `detect: true` compiles every registered grammar on
first use; narrowing the scan keeps auto-detection and drops that cost.
Explicitly labelled blocks are unaffected.

Measured on a 394-character answer delivered as 19 chunks of 20 characters:

- First code block on a cold page: ~132 ms before, ~4 ms after.
- A burst: 48.6 ms of CPU over 19 renders before, 11.0 ms over 6 after.
- A slow stream (60 ms per chunk): 48.6 ms before, 29.6 ms after.
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.

The react/react-dom alias moves 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 unchanged.

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 build hook
  that already existed for this purpose. The minimal build ships 0 KaTeX font
  faces; the full build is unchanged.
- `Pre.jsx`, now that the renderer has its own code-block toolbar, and the
  orphaned `change-children-font-size.mjs` helper.
- `react-markdown`, `remark-breaks`, `rehype-raw`, `github-markdown-css`,
  `parse5` and the `parse5` webpack alias (nothing in `src` imported it, and the
  alias broke `hast-util-raw`).

Rendering fixes that came with the swap: the renderer stylesheets are imported by
the content-script entry so every variant ships them (they used to land in a
chunk whose CSS is never packaged), list markers are drawn by the browser
(`list-markers.mjs`) so bullets nested in a numbered list stay bullets and a list
that resumes at another number keeps its `start`, and a thrown error is stored as
its message, since the renderer takes text and would otherwise break on an
`Error` object.
Copilot AI balanced review requested due to automatic review settings October 3, 2026 10:05

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a3522d1b-f6f7-4365-91d1-bfc2bc0e6281
📥 Commits

Reviewing files that changed from the base of the PR and between 7df2e6c and fc80bc3.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (26)
  • build.mjs
  • package.json
  • src/_locales/en/main.json
  • src/_locales/zh-hans/main.json
  • src/_locales/zh-hant/main.json
  • src/components/ConversationCard/answer-buffer.mjs
  • src/components/ConversationCard/index.jsx
  • src/components/ConversationItem/index.jsx
  • src/components/MarkdownRender/Pre.jsx
  • src/components/MarkdownRender/highlight-options.mjs
  • src/components/MarkdownRender/list-markers.mjs
  • src/components/MarkdownRender/markdown-without-katex.jsx
  • src/components/MarkdownRender/markdown.jsx
  • src/components/MarkdownRender/math-plugin-without-katex.mjs
  • src/components/MarkdownRender/math-plugin.mjs
  • src/components/MarkdownRender/mykatex-without-katex.css
  • src/components/MarkdownRender/stream-delta.mjs
  • src/content-script/index.jsx
  • src/content-script/styles.scss
  • src/utils/change-children-font-size.mjs
  • src/utils/index.mjs
  • tests/setup/content-script-selection-toolbar-loader-hooks.mjs
  • tests/unit/components/answer-buffer.test.mjs
  • tests/unit/components/highlight-options.test.mjs
  • tests/unit/components/list-markers.test.mjs
  • tests/unit/components/stream-delta.test.mjs
 ___________________________________________________________________________________________________________
< A CodeRabbit review a day keeps the merge cop away: https://x.com/ThePrimeagen/status/1986151686839116209 >
 -----------------------------------------------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Render streamed replies incrementally with HyperMarkdown

✨ Enhancement ⚙️ Configuration changes 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Replace whole-answer Markdown parsing with incremental rendering to reduce work during long
 replies.
• Coalesce streamed snapshots per frame and finalize the latest answer without losing text.
• Adapt build variants, styles, dependencies, and tests for the new renderer.
Diagram

graph TD
  A["Answer snapshots"] --> B["Frame buffer"] --> C["Conversation item"] --> D["Delta adapter"] --> E["HyperMarkdown"] --> F["Rendered reply"]
  G["Build variants"] --> E
  H["Entry styles"] --> F
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Retain react-markdown with block-level memoization
  • ➕ Preserves existing rendering behavior and custom code-block controls.
  • ➕ Avoids a renderer and React-peer migration.
  • ➖ Requires maintaining custom block-boundary and streaming logic.
  • ➖ May not avoid reparsing changing blocks and reconciling earlier output as effectively.

Recommendation: HyperMarkdown is a reasonable choice for incremental parsing without building a custom renderer. Review the user-visible behavior changes—particularly the removed per-block font-size selector—and verify full and KaTeX-free artifacts before merging.

Files changed (24) +1798 / -1034

Enhancement (10) +249 / -200
main.jsonAdd English thinking-state labels +2/-0

Add English thinking-state labels

• Adds labels for thinking content and elapsed thinking time used by the new renderer.

src/_locales/en/main.json

main.jsonTranslate elapsed thinking time into Simplified Chinese +1/-0

Translate elapsed thinking time into Simplified Chinese

• Adds the renderer's elapsed-thinking-time label while retaining the existing thinking-content translation.

src/_locales/zh-hans/main.json

main.jsonTranslate elapsed thinking time into Traditional Chinese +1/-0

Translate elapsed thinking time into Traditional Chinese

• Adds the renderer's elapsed-thinking-time label while retaining the existing thinking-content translation.

src/_locales/zh-hant/main.json

answer-buffer.mjsCoalesce answer snapshots by animation frame +71/-0

Coalesce answer snapshots by animation frame

• Buffers the newest streamed answer for one render per frame, with a timer fallback for hosts without animation frames. Supports flushing on completion and discarding pending updates on lifecycle changes.

src/components/ConversationCard/answer-buffer.mjs

index.jsxBuffer streaming updates and pass completion state +23/-3

Buffer streaming updates and pass completion state

• Routes streamed answer snapshots through the frame buffer, flushes before completion or error handling, and discards stale frames on retry, deletion, or unmount. Passes the answer's done state to conversation items and converts caught errors to text.

src/components/ConversationCard/index.jsx

index.jsxForward answer completion to Markdown rendering +3/-2

Forward answer completion to Markdown rendering

• Accepts a done flag and passes it to MarkdownRender so the renderer can finalize a completed stream.

src/components/ConversationItem/index.jsx

highlight-options.mjsLimit automatic syntax detection to common languages +34/-0

Limit automatic syntax detection to common languages

• Defines shared highlighting options that narrow automatic language detection to avoid compiling every grammar on first use. Explicitly labeled languages remain available, and unknown labels are tolerated.

src/components/MarkdownRender/highlight-options.mjs

markdown.jsxReplace react-markdown with streaming HyperMarkdown +78/-195

Replace react-markdown with streaming HyperMarkdown

• Converts cumulative reply snapshots into writes and finalization calls for HyperMarkdown, while retaining custom hyperlinks and configuring math, highlighting, translations, and supported controls. Normalizes ordered-list starts as the renderer changes the DOM; the former custom code-block font-size selector is no longer present.

src/components/MarkdownRender/markdown.jsx

math-plugin.mjsExpose HyperMarkdown's KaTeX plugin +4/-0

Expose HyperMarkdown's KaTeX plugin

• Provides the math-plugin entry used by full builds and replaced at build time for KaTeX-free variants.

src/components/MarkdownRender/math-plugin.mjs

stream-delta.mjsAdapt cumulative snapshots to renderer deltas +32/-0

Adapt cumulative snapshots to renderer deltas

• Writes only newly appended text, resets when a snapshot no longer extends the previous one, and finalizes a completed answer once—even when completion adds no text.

src/components/MarkdownRender/stream-delta.mjs

Bug fix (2) +41 / -0
list-markers.mjsCorrect zero-based streaming list starts +13/-0

Correct zero-based streaming list starts

• Removes HyperMarkdown's temporary start="0" attribute from ordered lists so native browser markers begin at one. Preserves explicit nonzero starting numbers.

src/components/MarkdownRender/list-markers.mjs

styles.scssUse native markers for HyperMarkdown lists +28/-0

Use native markers for HyperMarkdown lists

• Overrides renderer list-marker rules so nested bullets retain their type and resumed ordered lists honor their starting number.

src/content-script/styles.scss

Refactor (1) +0 / -1
index.mjsRemove the obsolete font-size utility export +0/-1

Remove the obsolete font-size utility export

• Stops exporting the helper formerly used by the removed custom code-block wrapper.

src/utils/index.mjs

Tests (5) +314 / -0
content-script-selection-toolbar-loader-hooks.mjsStub new content-script stylesheet imports +6/-0

Stub new content-script stylesheet imports

• Adds loader stubs for HyperMarkdown, tooltip, and KaTeX CSS so content-script tests can load the updated entry.

tests/setup/content-script-selection-toolbar-loader-hooks.mjs

answer-buffer.test.mjsTest streamed-answer frame buffering +139/-0

Test streamed-answer frame buffering

• Covers burst coalescing, subsequent frames, final flushes, discarded frames, and animation-frame or timer scheduling.

tests/unit/components/answer-buffer.test.mjs

highlight-options.test.mjsTest narrowed syntax detection +62/-0

Test narrowed syntax detection

• Checks automatic detection within the configured subset, highlighting for explicitly labeled languages outside it, and graceful handling of unknown labels.

tests/unit/components/highlight-options.test.mjs

list-markers.test.mjsTest ordered-list start normalization +33/-0

Test ordered-list start normalization

• Verifies zero-based starts are removed, genuine resumed starts remain, nested lists are corrected, and a missing container is safe.

tests/unit/components/list-markers.test.mjs

stream-delta.test.mjsTest snapshot-to-delta conversion +74/-0

Test snapshot-to-delta conversion

• Covers initial writes, appended and unchanged snapshots, one-time completion, resets for changed content, and completed stored answers.

tests/unit/components/stream-delta.test.mjs

Other (6) +1194 / -833
build.mjsSelect math modules and CSS by build variant +13/-4

Select math modules and CSS by build variant

• Replaces the KaTeX-free renderer swap with targeted math-plugin and stylesheet replacements. Removes the root parse5 alias so newer transitive parsers can resolve their required version.

build.mjs

package-lock.jsonLock the HyperMarkdown dependency graph +1161/-817

Lock the HyperMarkdown dependency graph

• Records HyperMarkdown, updated Preact compatibility aliases, KaTeX and highlighting packages, tooltip support, and newer unified parsing dependencies. Removes obsolete renderer dependencies and their transitive packages.

package-lock.json

package.jsonReplace renderer dependencies and align peer versions +12/-12

Replace renderer dependencies and align peer versions

• Adds HyperMarkdown and its required runtime packages, updates React and React DOM aliases to @preact/compat 18, and aligns math and highlighting versions. Removes unused Markdown dependencies and adds parser packages needed by tests.

package.json

math-plugin-without-katex.mjsDisable math rendering in KaTeX-free builds +2/-0

Disable math rendering in KaTeX-free builds

• Provides a replacement math-plugin export that leaves math as literal text when KaTeX is omitted.

src/components/MarkdownRender/math-plugin-without-katex.mjs

mykatex-without-katex.cssProvide an empty KaTeX-free stylesheet +1/-0

Provide an empty KaTeX-free stylesheet

• Supplies the build-time replacement for the KaTeX stylesheet so minimal artifacts do not ship its font faces.

src/components/MarkdownRender/mykatex-without-katex.css

index.jsxPackage renderer styles with the content-script entry +5/-0

Package renderer styles with the content-script entry

• Imports HyperMarkdown, tooltip, and KaTeX styles at the entry point so build variants include CSS that would otherwise remain in an unshipped shared chunk.

src/content-script/index.jsx

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (2) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. One renderer attribute uses double quotes 📘 Rule violation ⚙ Maintainability
Description
MarkdownRender adds dir="auto" as a double-quoted JSX attribute value. The attribute is on the
new renderer wrapper, so this changed line breaks the single-quote convention.
Code

src/components/MarkdownRender/markdown.jsx[74]

+    <div dir="auto" ref={containerRef}>
Evidence
The changed JSX attribute at line 74 uses double quotes, which checklist item 2261919 explicitly
includes.

Rule 2261919: Use single quotes for string literals in JavaScript/JSX
src/components/MarkdownRender/markdown.jsx[74-74]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new renderer wrapper uses a double-quoted JSX attribute value.

## Fix Focus Areas
- src/components/MarkdownRender/markdown.jsx[74-74]

## Recommended Fix
Change the `dir` attribute value to single quotes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


2. The new thought label stays English 📘 Rule violation ⚙ Maintainability
Description
MarkdownRender requests Thought for {seconds}s, but the new key is present only in the English
and two Chinese locale files. For the other registered locales, the configured English fallback
supplies the label instead of a locale-specific value or placeholder.
Code

src/components/MarkdownRender/markdown.jsx[69]

+    () => ({ thinking: t('Thinking Content'), thoughtFor: t('Thought for {seconds}s') }),
Evidence
The changed renderer requests the new key. It appears in the English and two Chinese locale files
but not the other locales registered in resources.mjs; i18n-react.mjs configures English as the
fallback.

Rule 2262059: Add new English localization keys before other locales
src/components/MarkdownRender/markdown.jsx[67-70]
src/_locales/en/main.json[34-35]
src/_locales/resources.mjs[1-52]
src/_locales/i18n-react.mjs[4-10]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new thought-duration label has no entry in most registered locales and falls back to English.

## Fix Focus Areas
- src/components/MarkdownRender/markdown.jsx[69-69]
- src/_locales/en/main.json[35-35]
- src/_locales/resources.mjs[1-52]

## Recommended Fix
Add `Thought for {seconds}s` to each remaining registered locale file with a translation or a clearly marked placeholder.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


3. Zero-based lists display from one 🐞 Bug ≡ Correctness
Description
normalizeListStarts removes start="0" from every ordered list, without distinguishing a
temporary streaming value from a list explicitly numbered from 0.. When a reply contains a
zero-based Markdown list, the browser numbers it from one, and the MutationObserver repeats the
cleanup after subsequent renders, including after the answer is finalized.
Code

src/components/MarkdownRender/list-markers.mjs[R9-11]

+  const lists = container?.querySelectorAll?.('ol[start="0"]')
+  if (!lists) return 0
+  for (const list of lists) list.removeAttribute('start')
Evidence
The ol[start="0"] selector and attribute removal do not check whether the zero is explicit or
renderer-generated. markdown.jsx installs this cleanup on the live container and runs it on DOM
mutations, while the existing test uses only a synthetic zero-start element and therefore does not
distinguish the two cases.

src/components/MarkdownRender/list-markers.mjs[2-12]
src/components/MarkdownRender/markdown.jsx[41-53]
tests/unit/components/list-markers.test.mjs[11-22]
src/components/MarkdownRender/list-markers.mjs[1-13]
src/components/MarkdownRender/markdown.jsx[41-54]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`normalizeListStarts` treats an explicitly zero-based Markdown list like a list with a temporary renderer-generated `start="0"`, so the browser displays the explicit list starting at one.

## Fix Focus Areas
- src/components/MarkdownRender/list-markers.mjs[8-13]
- src/components/MarkdownRender/markdown.jsx[41-54]
- tests/unit/components/list-markers.test.mjs[11-22]

## Recommended Fix
Limit attribute removal to lists known to have a temporary streaming start value, rather than every `ol[start="0"]`. For example, distinguish lists by whether the first list item's source number is zero, and ensure cleanup after finalization preserves explicit zero-based lists. Add a rendering test for Markdown beginning with `0.` and confirm it remains numbered from zero.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


View medium (2)
4. Lettered lists display as numbered lists 🐞 Bug ≡ Correctness
Description
The new .hypermarkdown ol rule forces list-style-type: decimal !important regardless of an
ordered list's type attribute. When rendered HTML contains an alphabetic or Roman ordered list,
this rule overrides the existing type-specific styles while the new li::before rule suppresses the
renderer's markers.
Code

src/content-script/styles.scss[R161-163]

+  .markdown-body .hypermarkdown ol {
+    list-style-type: decimal !important;
+  }
Evidence
The new decimal declaration is marked !important, whereas the stylesheet's existing alphabetic and
Roman list rules are not. The new pseudo-element rule also removes the renderer-drawn marker,
leaving the forced native decimal marker.

src/content-script/styles.scss[155-175]
src/content-script/styles.scss[697-714]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The native-marker override forces decimal markers even for ordered lists with alphabetic or Roman types.
## Fix Focus Areas
- src/content-script/styles.scss[155-175]
- src/content-script/styles.scss[697-714]
## Recommended Fix
Apply the decimal fallback only to ordered lists without a non-decimal `type`, or add matching higher-priority rules for supported types. Verify lettered and Roman lists retain their markers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


5. A reply cut off by an error never finishes rendering 🐞 Bug ≡ Correctness
Description
createStreamDelta.next only asks the renderer to finalize once done is true, and done only
becomes true in the msg.done branch of portMessageListener. When a provider error arrives after
part of the answer has streamed, the msg.error branch calls flush() (which renders the partial
text with done=false) and the default case then appends a separate error item, so the partial
answer keeps done=false and HyperMarkdown leaves its last block in the streaming state for good.
Code

src/components/ConversationCard/index.jsx[R266-268]

    if (msg.error) {
+      answerBufferRef.current.flush()
      const retryRecord = retryRecordRef.current
Evidence
updateAnswer sets done=false unless told otherwise, and the buffer's render callback calls it
with no done argument. In the error default branch, lastItem.content holds the partial answer
rather than gpt-loading, so the code appends a new error item and never touches the answer item
again. ConversationItem passes data.done to MarkdownRender, and stream-delta returns
finalize: true only when done is true. Before this PR done had no effect on rendering; now it
controls whether the renderer finalizes.

src/components/ConversationCard/index.jsx[211-231]
src/components/ConversationCard/index.jsx[266-327]
src/components/MarkdownRender/stream-delta.mjs[11-30]
src/components/MarkdownRender/markdown.jsx[58-65]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When `msg.error` arrives after a partial answer has streamed, the answer item keeps `done=false`, so the HyperMarkdown renderer never finalizes its last block.

## Fix Focus Areas
- src/components/ConversationCard/index.jsx[266-327]

## Recommended Fix
In the `msg.error` branch, after `answerBufferRef.current.flush()`, mark the existing answer item as done before adding the error, for example with `updateAnswer('', true, 'answer', true)` when the last answer item is a real partial answer and not the loading placeholder. Alternatively, set `done: true` on that item inside the `setConversationItemData` updater in the default case.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


Grey Divider

Context sources
✅ Compliance rules (platform): 6 rules
Review mode: 🧠 Deep: This renderer migration has substantial new streaming, parsing, build, styling, and dependency logic across many independent edit sites, creating a high density of plausible cross-path regressions that benefits from redundant review.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

{props.children.replace('</think>', '\n\n</think>\n\n')}
</ReactMarkdown>
return (
<div dir="auto" ref={containerRef}>

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.

Remediation recommended

1. One renderer attribute uses double quotes 📘 Rule violation ⚙ Maintainability

MarkdownRender adds dir="auto" as a double-quoted JSX attribute value. The attribute is on the
new renderer wrapper, so this changed line breaks the single-quote convention.
Agent Prompt
## Issue description
The new renderer wrapper uses a double-quoted JSX attribute value.

## Fix Focus Areas
- src/components/MarkdownRender/markdown.jsx[74-74]

## Recommended Fix
Change the `dir` attribute value to single quotes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

'cite',
// `{seconds}` is filled in by the renderer, and is not i18next interpolation syntax.
const translations = useMemo(
() => ({ thinking: t('Thinking Content'), thoughtFor: t('Thought for {seconds}s') }),

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.

Remediation recommended

2. The new thought label stays english 📘 Rule violation ⚙ Maintainability

MarkdownRender requests Thought for {seconds}s, but the new key is present only in the English
and two Chinese locale files. For the other registered locales, the configured English fallback
supplies the label instead of a locale-specific value or placeholder.
Agent Prompt
## Issue description
The new thought-duration label has no entry in most registered locales and falls back to English.

## Fix Focus Areas
- src/components/MarkdownRender/markdown.jsx[69-69]
- src/_locales/en/main.json[35-35]
- src/_locales/resources.mjs[1-52]

## Recommended Fix
Add `Thought for {seconds}s` to each remaining registered locale file with a translation or a clearly marked placeholder.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Comment on lines +9 to +11
const lists = container?.querySelectorAll?.('ol[start="0"]')
if (!lists) return 0
for (const list of lists) list.removeAttribute('start')

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.

Remediation recommended

3. Zero-based lists display from one 🐞 Bug ≡ Correctness

normalizeListStarts removes start="0" from every ordered list, without distinguishing a
temporary streaming value from a list explicitly numbered from 0.. When a reply contains a
zero-based Markdown list, the browser numbers it from one, and the MutationObserver repeats the
cleanup after subsequent renders, including after the answer is finalized.
Agent Prompt
## Issue description
`normalizeListStarts` treats an explicitly zero-based Markdown list like a list with a temporary renderer-generated `start="0"`, so the browser displays the explicit list starting at one.

## Fix Focus Areas
- src/components/MarkdownRender/list-markers.mjs[8-13]
- src/components/MarkdownRender/markdown.jsx[41-54]
- tests/unit/components/list-markers.test.mjs[11-22]

## Recommended Fix
Limit attribute removal to lists known to have a temporary streaming start value, rather than every `ol[start="0"]`. For example, distinguish lists by whether the first list item's source number is zero, and ensure cleanup after finalization preserves explicit zero-based lists. Add a rendering test for Markdown beginning with `0.` and confirm it remains numbered from zero.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Comment on lines +161 to +163
.markdown-body .hypermarkdown ol {
list-style-type: decimal !important;
}

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.

Remediation recommended

4. Lettered lists display as numbered lists 🐞 Bug ≡ Correctness

The new .hypermarkdown ol rule forces list-style-type: decimal !important regardless of an
ordered list's type attribute. When rendered HTML contains an alphabetic or Roman ordered list,
this rule overrides the existing type-specific styles while the new li::before rule suppresses the
renderer's markers.
Agent Prompt
## Issue description
The native-marker override forces decimal markers even for ordered lists with alphabetic or Roman types.
## Fix Focus Areas
- src/content-script/styles.scss[155-175]
- src/content-script/styles.scss[697-714]
## Recommended Fix
Apply the decimal fallback only to ordered lists without a non-decimal `type`, or add matching higher-priority rules for supported types. Verify lettered and Roman lists retain their markers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Comment on lines 266 to 268
if (msg.error) {
answerBufferRef.current.flush()
const retryRecord = retryRecordRef.current

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.

Remediation recommended

5. A reply cut off by an error never finishes rendering 🐞 Bug ≡ Correctness

createStreamDelta.next only asks the renderer to finalize once done is true, and done only
becomes true in the msg.done branch of portMessageListener. When a provider error arrives after
part of the answer has streamed, the msg.error branch calls flush() (which renders the partial
text with done=false) and the default case then appends a separate error item, so the partial
answer keeps done=false and HyperMarkdown leaves its last block in the streaming state for good.
Agent Prompt
## Issue description
When `msg.error` arrives after a partial answer has streamed, the answer item keeps `done=false`, so the HyperMarkdown renderer never finalizes its last block.

## Fix Focus Areas
- src/components/ConversationCard/index.jsx[266-327]

## Recommended Fix
In the `msg.error` branch, after `answerBufferRef.current.flush()`, mark the existing answer item as done before adding the error, for example with `updateAnswer('', true, 'answer', true)` when the last answer item is a real partial answer and not the loading placeholder. Alternatively, set `done: true` on that item inside the `setConversationItemData` updater in the default case.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

The committed package-lock.json now resolves 135 packages from registry.npmmirror.com; master had zero. Please regenerate the lockfile against the official registry before merging.

Reviewed changes

  • Renderer migration — react-markdown is replaced by @aeven-ai/hypermarkdown@0.4.4. MarkdownRender mounts <HyperMarkdown streaming> and drives its imperative handle; stream-delta.mjs converts cumulative snapshots into deltas.
  • Streaming coalescing — answer-buffer.mjs batches chunk bursts to one render per animation frame; ConversationCard pushes, flushes on done/error, and discards on retry/clear.
  • Build variants — the minimal build swaps math-plugin.mjs→math-plugin-without-katex.mjs and mykatex.min.css→mykatex-without-katex.css; the old markdown.jsx→markdown-without-katex.jsx swap is removed. Renderer stylesheets moved to the content-script entry, which IndependentPanel.html links.
  • Cleanup — Pre.jsx, markdown-without-katex.jsx, change-children-font-size.mjs, the parse5 alias, and the github-markdown-css/parse5/react-markdown/rehype-raw/remark-breaks deps are gone; the react/react-dom alias is bumped to @preact/compat@^18.3.2.
  • New behavior — browser-drawn list markers (list-markers.mjs), a detection subset for highlighting (highlight-options.mjs), and Thinking Content / Thought for {seconds}s translations.

I verified against fc80bc3: npm test (1111 pass), npm run lint clean, npm run build produces all four variants with the expected artifacts. The minimal CSS has 0 @font-face and its JS has 0 KaTeX references; the full build keeps its 26 font faces. CSS packaging is correct because IndependentPanel.html links content-script.css.

⚠️ Lockfile now resolves 135 packages from a third-party mirror

package-lock.json mixes registries: 962 entries point at registry.npmjs.org and 135 at registry.npmmirror.com (Alibaba's mirror). The base lockfile had none — every entry was npmjs. The mirror-hosted set is the new @aeven-ai/hypermarkdown transitive tree plus peers (hast-util-*, rehype-*, remark-*, katex, tippy.js, @popperjs/core, @types/*). Integrity hashes still guard tarball contents, but npm ci in CI and on other machines will now fetch from a third-party host, which is both a provenance inconsistency and an availability dependency (some environments block non-npmjs hosts).

Technical details
# Lockfile registry provenance

## Affected sites
- `package-lock.json` — 135 `"resolved"` URLs under `https://registry.npmmirror.com`,
  introduced by this PR. `master`'s lockfile (`git show master:package-lock.json`) has
  1072 resolved entries, all `https://registry.npmjs.org`.

## Required outcome
- The committed lockfile should resolve from `https://registry.npmjs.org` (or another
  single, intentionally chosen registry), not carry entries the author's local npm
  config injected.

## Suggested approach (optional)
- Regenerate without the mirror configured, e.g.
  `npm install --package-lock-only --registry https://registry.npmjs.org`,
  then confirm `grep -c registry.npmmirror.com package-lock.json` is `0`.
- Worth checking whether the PR author's environment has `registry=https://registry.npmmirror.com`
  in `~/.npmrc`, since that is the usual source of a mixed lockfile.

ℹ️ remark-breaks removal changes soft line breaks outside paragraphs

The old renderer passed remarkPlugins={[remarkMath, remarkGfm, remarkBreaks]}. HyperMarkdown's pipeline (remark-parse → remark-gfm → remark-rehype, per its lib/processors.ts) has no break plugin, so a single newline is now a soft break everywhere. HyperMarkdown's stylesheet sets white-space: pre-wrap on .hypermarkdown p, so soft breaks inside paragraphs still render as line breaks — but <li>, headings, <blockquote>, and table cells have no such rule and will collapse them to spaces. The PR body lists remark-breaks as "removed as redundant"; it is redundant for paragraphs only.

Technical details
# Soft line breaks outside paragraphs

## Affected sites
- `src/components/MarkdownRender/markdown.jsx` — old `remarkPlugins={[remarkMath, remarkGfm, remarkBreaks]}` is replaced by `@aeven-ai/hypermarkdown`'s built-in pipeline, which has no equivalent.
- `node_modules/@aeven-ai/hypermarkdown/dist/hypermarkdown.css` — only `.hypermarkdown p` carries `white-space: pre-wrap`; `li`/`h1-6`/`blockquote`/`td` do not.

## Evidence
- Pipeline parity probe (`unified().use(remarkParse).use(remarkGfm).use(remarkRehype)` on
  `- First point\n  continued on next line`) yields `<li>First point\ncontinued on next line</li>`
  with no `<br>` node. With `remark-breaks` this was a `<br>`.
- Paragraph content is unaffected visually because of the `pre-wrap` rule.

## Open questions for the human (optional)
- HyperMarkdown does not accept arbitrary `remarkPlugins` (its README says to use its typed
  plugins), so restoring the behavior needs an upstream break/`pre-wrap` option or a host-side
  transform. Is losing line breaks in tight list items / headings / blockquotes acceptable?

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@ecokayiza

Copy link
Copy Markdown
Author

Closing this one to fold the HyperMarkdown migration and the reasoning work into a single PR — HyperMarkdown already renders reasoning blocks natively, so the two land more naturally together. A combined PR replaces it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants