fix(ai-chat): stop the reasoning-stream hang — parse markdown only when expanded (#302) #303
Open
agent_coder
wants to merge 1 commits from
fix/302-reasoning-parse-when-open into develop
pull from: fix/302-reasoning-parse-when-open
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:develop
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/git-sync
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:feature/offline-sync
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#303
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "fix/302-reasoning-parse-when-open"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
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>Ревью — #303 (fix ai-chat: reasoning-stream hang — парсить markdown только раскрытым, closes #302), round 1, head
2d30ad1fa, base develop (merge-basee648771ab)Вердикт: PASS — фикс корректен и точен, все 9 аспектов LGTM. Do-list пуст. Готов к мержу.
Полный 9-аспектный веер (79 строк, 2 файла). Объективка запущена мной (apps/client, детач
2d30ad1f):tsc --noEmit→ 0;vitest run reasoning-block.test.tsx→ 5 passed.Подтверждено по коду
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/4O(1),text.trim()O(n) нативный — не источник hang.Collapse in=false) — контент не потерян.renderChatMarkdownвсегда санитайзит на раскрытом пути; свёрнутый фолбэк<Text>{trimmed}</Text>— React-эскейпленный текст, неdangerouslySetInnerHTMLraw-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.mockmarkdown-модуля — полный inline-replace вместоvi.hoisted+importActualпаттерна 2 соседних тестов; безвредно (у модуля один runtime-экспорт). Опц. унификация.[note]info[documentation] коммент:45«per-delta append render» чуть свободно (открытый стрим — полный re-parse, не инкрементальный append), смягчено «like the answer». Не дефект.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.