fix(ai-chat): stop the reasoning-stream hang — parse markdown only when expanded (#302) #303

Merged
vvzvlad merged 1 commits from fix/302-reasoning-parse-when-open into develop 2026-07-03 18:02:17 +03:00
Collaborator

Summary

Чинит #302: AI-чат виснет во время фазы «размышления» (reasoning-стрим). closes #302.

Причина. reasoning-block.tsx мемоизировал рендер markdown по [trimmed], а reasoning-текст стримится и растёт с каждой троттленной дельтой (~20 Гц). Значит на КАЖДОЙ дельте пере-парсился ВЕСЬ растущий текст (marked + DOMPurify) — O(n²)-шторм, забивающий main-thread и морозящий чат. Хуже: блок свёрнут по умолчанию, т.е. весь этот тяжёлый парсинг шёл для НЕВИДИМОГО тела (html показывается только внутри <Collapse in={open}>).

Фикс. Парсить markdown reasoning только пока блок раскрыт (open): свёрнутый показывает дешёвый raw-text фолбэк и не парсит ничего; при раскрытии — один парс текущего текста (мгновенно по клику), дальше при стриминге в открытом виде обычный per-delta append, как у ответа.

How verified

  • Тест: renderChatMarkdown НЕ вызывается пока блок свёрнут, и вызывается ровно один раз при раскрытии (пиннит фикс, не-вакуозен — до фикса парс шёл на каждый рендер).
  • vitest reasoning-block — 5 passed; tsc — 0 по затронутым.

Checklist

  • чат не виснет на длинном reasoning (свёрнутый блок не парсит markdown)
  • раскрытый блок рендерит markdown как раньше
## Summary Чинит #302: AI-чат виснет во время фазы «размышления» (reasoning-стрим). closes #302. **Причина.** `reasoning-block.tsx` мемоизировал рендер markdown по `[trimmed]`, а reasoning-текст стримится и растёт с каждой троттленной дельтой (~20 Гц). Значит на КАЖДОЙ дельте пере-парсился ВЕСЬ растущий текст (`marked` + `DOMPurify`) — O(n²)-шторм, забивающий main-thread и морозящий чат. Хуже: блок свёрнут по умолчанию, т.е. весь этот тяжёлый парсинг шёл для НЕВИДИМОГО тела (html показывается только внутри `<Collapse in={open}>`). **Фикс.** Парсить markdown reasoning только пока блок раскрыт (`open`): свёрнутый показывает дешёвый raw-text фолбэк и не парсит ничего; при раскрытии — один парс текущего текста (мгновенно по клику), дальше при стриминге в открытом виде обычный per-delta append, как у ответа. ## How verified - Тест: `renderChatMarkdown` НЕ вызывается пока блок свёрнут, и вызывается ровно один раз при раскрытии (пиннит фикс, не-вакуозен — до фикса парс шёл на каждый рендер). - `vitest reasoning-block` — 5 passed; `tsc` — 0 по затронутым. ## Checklist - [x] чат не виснет на длинном reasoning (свёрнутый блок не парсит markdown) - [x] раскрытый блок рендерит markdown как раньше
agent_coder added 1 commit 2026-07-03 05:02:20 +03:00
The reasoning block memoized its markdown render on [trimmed] alone, so as the
reasoning text streamed in it re-parsed the whole, ever-growing text (marked +
DOMPurify) on every throttled delta (~20Hz) — an O(n^2) CPU storm that pinned the
main thread and froze the chat during a long "thinking" phase. Worse, the block is
collapsed by default, so all that parsing was for a hidden body the user never sees
(html is only shown inside <Collapse in={open}>).

Gate the parse on `open`: collapsed shows the cheap raw-text fallback and does no
markdown parsing; expanding parses the current text once (an instant user click), and
further streaming while open is the normal per-delta append render, like the answer.

Test: assert renderChatMarkdown is not called while collapsed and is called once on
expand.

closes #302

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-03 05:02:22 +03:00
Collaborator

Ревью — #303 (fix ai-chat: reasoning-stream hang — парсить markdown только раскрытым, closes #302), round 1, head 2d30ad1fa, base develop (merge-base e648771ab)

Вердикт: PASS — фикс корректен и точен, все 9 аспектов LGTM. Do-list пуст. Готов к мержу.

Полный 9-аспектный веер (79 строк, 2 файла). Объективка запущена мной (apps/client, детач 2d30ad1f): tsc --noEmit0; vitest run reasoning-block.test.tsx5 passed.

Подтверждено по коду

  • Фикс закрывает #302: html-useMemo теперь гейтит на openopen && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "", deps [open, trimmed] (reasoning-block.tsx:46-50). open = useState(false) (дефолт свёрнут), драйвит <Collapse in={open}>. Свёрнутый (общий случай длинного «thinking»-стрима) → html="" → renderChatMarkdown (marked+DOMPurify) НЕ вызывается → O(n²)-шторм пере-парса скрытого растущего тела устранён. При раскрытии — один парс текущего текста; дальше per-delta пока открыт — как у ответа (MarkdownPart парсит так же), т.е. известный pre-existing предел, не регресс #302. estimateTokens = len/4 O(1), text.trim() O(n) нативный — не источник hang.
  • Регрессий нет: раскрытый блок рендерит тот же html (тот же вызов), раскрытие дострима показывает распарсенный markdown синхронно (без вспышки raw), счётчик токенов/заголовок независимы от html. Свёрнутое тело было и раньше скрыто (Collapse in=false) — контент не потерян.
  • Security: DOMPurify не байпаснут — renderChatMarkdown всегда санитайзит на раскрытом пути; свёрнутый фолбэк <Text>{trimmed}</Text> — React-эскейпленный текст, не dangerouslySetInnerHTML raw-markdown. Инъекций нет.
  • Тест не-вакуозен: мок renderChatMarkdown на шве (vi.mock), ассерт «НЕ вызван свёрнутым» + «вызван 1 раз при click-раскрытии». До-фикс мемо [trimmed] парсил на mount независимо от open → not.toHaveBeenCalled() краснел бы. getByRole("button") — единственная кнопка (тоггл). Coherence/simpl/conv/docs/arch — LGTM (arch: правильная ось оптимизации, когерентно со стримингом ответа).

Do — apply these, then re-review

(нет)


DROP — кодеру НЕ делать · калибровочный лог (для оператора)

  • [below-threshold] low [test-coverage] тест рендерит один раз статически; сам #302-сценарий (rerender с растущим текстом ПОКА свёрнут → всё ещё 0 парсов) не прогнан последовательностью. Ядро-мутант (снять open-гейт) уже убит существующим ассертом; доп-тест — durability. Приемлемо к мержу.
  • [style/linter] info [conventions] vi.mock markdown-модуля — полный inline-replace вместо vi.hoisted+importActual паттерна 2 соседних тестов; безвредно (у модуля один runtime-экспорт). Опц. унификация.
  • [note] info [documentation] коммент :45 «per-delta append render» чуть свободно (открытый стрим — полный re-parse, не инкрементальный append), смягчено «like the answer». Не дефект.
## Ревью — #303 (fix ai-chat: reasoning-stream hang — парсить markdown только раскрытым, closes #302), round 1, head `2d30ad1fa`, base develop (merge-base `e648771ab`) **Вердикт: PASS** — фикс корректен и точен, все 9 аспектов LGTM. Do-list пуст. Готов к мержу. Полный 9-аспектный веер (79 строк, 2 файла). **Объективка запущена мной** (apps/client, детач `2d30ad1f`): `tsc --noEmit` → **0**; `vitest run reasoning-block.test.tsx` → **5 passed**. ### Подтверждено по коду - **Фикс закрывает #302:** html-`useMemo` теперь гейтит на `open` — `open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""`, deps `[open, trimmed]` (`reasoning-block.tsx:46-50`). `open` = `useState(false)` (дефолт свёрнут), драйвит `<Collapse in={open}>`. Свёрнутый (общий случай длинного «thinking»-стрима) → html="" → `renderChatMarkdown` (marked+DOMPurify) НЕ вызывается → O(n²)-шторм пере-парса скрытого растущего тела устранён. При раскрытии — один парс текущего текста; дальше per-delta пока открыт — **как у ответа** (`MarkdownPart` парсит так же), т.е. известный pre-existing предел, не регресс #302. `estimateTokens` = `len/4` O(1), `text.trim()` O(n) нативный — не источник hang. - **Регрессий нет:** раскрытый блок рендерит тот же html (тот же вызов), раскрытие дострима показывает распарсенный markdown синхронно (без вспышки raw), счётчик токенов/заголовок независимы от html. Свёрнутое тело было и раньше скрыто (`Collapse in=false`) — контент не потерян. - **Security:** DOMPurify не байпаснут — `renderChatMarkdown` всегда санитайзит на раскрытом пути; свёрнутый фолбэк `<Text>{trimmed}</Text>` — React-эскейпленный текст, не `dangerouslySetInnerHTML` raw-markdown. Инъекций нет. - **Тест не-вакуозен:** мок `renderChatMarkdown` на шве (`vi.mock`), ассерт «НЕ вызван свёрнутым» + «вызван 1 раз при click-раскрытии». До-фикс мемо `[trimmed]` парсил на mount независимо от open → `not.toHaveBeenCalled()` краснел бы. `getByRole("button")` — единственная кнопка (тоггл). Coherence/simpl/conv/docs/arch — LGTM (arch: правильная ось оптимизации, когерентно со стримингом ответа). ### Do — apply these, then re-review _(нет)_ --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `low` **[test-coverage]** тест рендерит один раз статически; сам #302-сценарий (rerender с растущим текстом ПОКА свёрнут → всё ещё 0 парсов) не прогнан последовательностью. Ядро-мутант (снять open-гейт) уже убит существующим ассертом; доп-тест — durability. Приемлемо к мержу. - `[style/linter]` `info` **[conventions]** `vi.mock` markdown-модуля — полный inline-replace вместо `vi.hoisted`+`importActual` паттерна 2 соседних тестов; безвредно (у модуля один runtime-экспорт). Опц. унификация. - `[note]` `info` **[documentation]** коммент `:45` «per-delta append render» чуть свободно (открытый стрим — полный re-parse, не инкрементальный append), смягчено «like the answer». Не дефект. <!-- state:review reviewed_head=2d30ad1fa2fc02c9fb2098e8b3fb2e312fbf027f round=1 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-03 05:35:06 +03:00
vvzvlad merged commit a63efa6920 into develop 2026-07-03 18:02:17 +03:00
vvzvlad deleted branch fix/302-reasoning-parse-when-open 2026-07-03 18:02:22 +03:00
Sign in to join this conversation.