perf(ai-chat): раскрытый Thinking-блок больше не ре-парсит markdown на каждую дельту (дыра #302, фриз в Safari) #323

Merged
vvzvlad merged 3 commits from perf/ai-chat-open-lag into develop 2026-07-04 05:00:31 +03:00
Collaborator

Проблема

После #302 чат всё равно «очень сильно тормозит даже на 20k токенов» (репорт пользователя). Подтверждённый сценарий: Safari + раскрытый блок «Thinking · N tokens» во время стрима — лагает весь интерфейс вкладки, в том числе при разворачивании свёрнутого окна посреди хода.

Диагностика (перф-стенд + Chrome-трейсы)

Собран dev-харнесс (apps/client/perf/), монтирующий реальный ChatThread на синтетический AI SDK v6 SSE-стрим (reasoning + tool-calls + markdown-ответ, пресеты 5k/20k/50k токенов). Результаты:

  • Маунт персистентного чата на ~47k токенов — 77 мс (открытие истории — не узкое место).
  • Стрим со свёрнутым reasoning (дефолт, путь #302) — 0 long tasks при 60 FPS вплоть до 143k токенов даже под 4× CPU-троттлингом.
  • Стрим с раскрытым reasoning — top-1 не-idle расход трейса: set innerHTML (411 мс) + marked/DOMPurify: раскрытый блок на каждую троттленную дельту (~20 Гц) прогонял весь растущий reasoning-текст через marked + DOMPurify и целиком переустанавливал dangerouslySetInnerHTML → O(n²). Это ровно та дыра, которую #302 оставил осознанно («раскрыт во время стрима»). В WebKit повторные innerHTML-свапы больших поддеревьев и ре-лейаут pre-wrap текста кратно дороже, чем в Chromium → фриз всей вкладки. Пока окно свёрнуто (display:none), JS-часть шторма продолжает молотить, а при развороте Safari разом лейаутит весь накопленный DOM.

Фикс

Инвариант рендера reasoning-блока теперь трёхчастный:

  1. Свёрнут → парсинга нет (без изменений, #302).
  2. Раскрыт + стримится → чанкованный plain-text (StreamingPlainText): текст режется по пустым строкам с инвариантом стабильного префикса (append-only рост никогда не сдвигает старые границы), каждый чанк — мемоизированный div, на дельту обновляется только текстовый узел хвостового чанка. Ни marked, ни DOMPurify, ни innerHTML.
  3. Финализирован + раскрыт → ровно один парс markdown.

Живость части определяется как part.state === "streaming" И ход жив И строка хвостовая:

  • ручной Stop во время «мышления» (SDK финализирует reasoning только по reasoning-end, finish/finish-step его не ставят) не оставляет блок навсегда в plain-text;
  • застрявшие части прошлых ходов не «оживают» при следующих ходах (нет flip-flop markdown→plain→markdown).

Ревью и тесты

  • 3 раунда ревью: 2×APPROVE WITH SUGGESTIONS (оба замечания — финализация без reasoning-end, tail-гейтинг — закрыты отдельными коммитами с регрессионными тестами), финальный — APPROVE без замечаний.
  • 245 клиентских тестов зелёные, tsc --noEmit чист.
  • Повторный трейс после фикса: per-delta marked/DOMPurify исчезли из хот-пата; финализированные раскрытые блоки рендерят markdown как раньше.

Содержимое

  • 351860ba — сам фикс (streaming-plain-text.tsx + правки reasoning-block / message-item / message-list + тесты).
  • d4d05c8e — dev-only перф-харнесс (apps/client/perf/, только vite dev, в прод-сборку не попадает) + .gitignore для локальных трейсов.

Замечание: ответ (MarkdownPart хвостового text-part) во время стрима по-прежнему ре-парсится целиком на дельту — осознанно вне скоупа (объёмы на порядок меньше reasoning); при необходимости к нему применим тот же чанкованный паттерн.

🤖 Generated with Claude Code

## Проблема После #302 чат всё равно «очень сильно тормозит даже на 20k токенов» (репорт пользователя). Подтверждённый сценарий: **Safari + раскрытый блок «Thinking · N tokens» во время стрима** — лагает весь интерфейс вкладки, в том числе при разворачивании свёрнутого окна посреди хода. ## Диагностика (перф-стенд + Chrome-трейсы) Собран dev-харнесс (`apps/client/perf/`), монтирующий реальный `ChatThread` на синтетический AI SDK v6 SSE-стрим (reasoning + tool-calls + markdown-ответ, пресеты 5k/20k/50k токенов). Результаты: - Маунт персистентного чата на ~47k токенов — **77 мс** (открытие истории — не узкое место). - Стрим со **свёрнутым** reasoning (дефолт, путь #302) — **0 long tasks при 60 FPS** вплоть до 143k токенов даже под 4× CPU-троттлингом. - Стрим с **раскрытым** reasoning — top-1 не-idle расход трейса: `set innerHTML` (411 мс) + marked/DOMPurify: раскрытый блок на каждую троттленную дельту (~20 Гц) прогонял **весь растущий** reasoning-текст через `marked` + `DOMPurify` и целиком переустанавливал `dangerouslySetInnerHTML` → O(n²). Это ровно та дыра, которую #302 оставил осознанно («раскрыт во время стрима»). В WebKit повторные innerHTML-свапы больших поддеревьев и ре-лейаут pre-wrap текста кратно дороже, чем в Chromium → фриз всей вкладки. Пока окно свёрнуто (`display:none`), JS-часть шторма продолжает молотить, а при развороте Safari разом лейаутит весь накопленный DOM. ## Фикс Инвариант рендера reasoning-блока теперь трёхчастный: 1. **Свёрнут** → парсинга нет (без изменений, #302). 2. **Раскрыт + стримится** → чанкованный plain-text (`StreamingPlainText`): текст режется по пустым строкам с инвариантом стабильного префикса (append-only рост никогда не сдвигает старые границы), каждый чанк — мемоизированный div, на дельту обновляется только текстовый узел хвостового чанка. Ни marked, ни DOMPurify, ни innerHTML. 3. **Финализирован + раскрыт** → ровно один парс markdown. Живость части определяется как `part.state === "streaming"` **И** ход жив **И** строка хвостовая: - ручной Stop во время «мышления» (SDK финализирует reasoning только по `reasoning-end`, `finish/finish-step` его не ставят) не оставляет блок навсегда в plain-text; - застрявшие части прошлых ходов не «оживают» при следующих ходах (нет flip-flop markdown→plain→markdown). ## Ревью и тесты - 3 раунда ревью: 2×APPROVE WITH SUGGESTIONS (оба замечания — финализация без `reasoning-end`, tail-гейтинг — закрыты отдельными коммитами с регрессионными тестами), финальный — **APPROVE без замечаний**. - 245 клиентских тестов зелёные, `tsc --noEmit` чист. - Повторный трейс после фикса: per-delta marked/DOMPurify исчезли из хот-пата; финализированные раскрытые блоки рендерят markdown как раньше. ## Содержимое - `351860ba` — сам фикс (`streaming-plain-text.tsx` + правки `reasoning-block` / `message-item` / `message-list` + тесты). - `d4d05c8e` — dev-only перф-харнесс (`apps/client/perf/`, только vite dev, в прод-сборку не попадает) + `.gitignore` для локальных трейсов. Замечание: ответ (`MarkdownPart` хвостового text-part) во время стрима по-прежнему ре-парсится целиком на дельту — осознанно вне скоупа (объёмы на порядок меньше reasoning); при необходимости к нему применим тот же чанкованный паттерн. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
agent_vscode added 2 commits 2026-07-04 03:56:54 +03:00
The expanded "Thinking" block re-ran marked+DOMPurify and re-set
dangerouslySetInnerHTML with the whole growing reasoning text on every
throttled stream delta (~20 Hz) — the O(n²) hole #302 deliberately left
open ("expanded while streaming"). In Safari this saturates the main
thread and freezes the entire tab during long agent runs, including
while the window is minimized (the JS storm keeps running) and on
re-expanding it mid-turn (one huge layout burst).

- streaming-plain-text.tsx (new): chunked plain-text renderer; chunks
  split at blank-line boundaries with an append-only stable-prefix
  invariant, so per delta only the tail chunk's text node updates —
  no marked, no DOMPurify, no innerHTML swaps.
- reasoning-block.tsx: parse markdown only when expanded AND finalized
  (one-time); while streaming, render chunked plain text; collapsed
  stays parse-free (#302 unchanged).
- message-item.tsx / message-list.tsx: reasoning liveness = part
  state:"streaming" AND the turn is live AND the row is the tail —
  a part stranded at state:"streaming" (manual Stop during thinking,
  or a provider that never emits reasoning-end) finalizes at turn end
  and never re-activates when later turns stream.

Verified with the Chrome perf harness: per-delta marked/DOMPurify work
is gone from the hot path; collapsed streaming stays at 0 long tasks
up to 143k tokens even at 4x CPU throttle; finalized expanded blocks
still render parsed markdown. 245 client tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mounts the real ChatThread against a synthetic AI SDK v6 UI-message SSE
stream (multi-step reasoning + getPage tool calls + markdown answer;
5k/20k/50k-token presets, 15/5 ms chunk cadence) with long-task, FPS
and mount-time instrumentation. Two scenarios: mount a persisted
transcript (open-chat cost) and stream a live turn through the real
useChat pipeline via a window.fetch patch scoped to /api/ai-chat/stream.

Served only by the vite dev server at /perf/ai-chat-perf.html; the
production build keeps its single index.html entry, so none of this
ships. Also ignore local trace dumps under .claude/perf-traces/.

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

Ревью — #323 (perf(ai-chat): раскрытый Thinking-блок больше не ре-парсит markdown на каждую дельту, дыра #302 / фриз в Safari), round 1, head d4d05c8e, base develop

Scope: полный дифф PR 795dde46..d4d05c8e — прод-правка ~200 строк (reasoning-block.tsx, новый streaming-plain-text.tsx, message-item.tsx, message-list.tsx) + dev-only перф-харнесс apps/client/perf/ (~900 строк, не шипается) + тесты. Полный веер 9 аспектов.

Вердикт: CHANGES — фикс правильный и по делу (O(n²)-шторм marked+DOMPurify+innerHTML на раскрытом стриме реально устранён, финальный рендер идентичен #302, инварианты целы, XSS не занесён, объективка зелёная). Но два маленьких in-scope DO: не запиннен security-инвариант нового plain-text-пути, и устаревший CSS-комментарий с битой перекрёстной ссылкой.

Объективка запущена мной (детач d4d05c8e, main-клон): client vitest (reasoning-block + streaming-plain-text + message-item + message-list) → 4 files, 26 passed; tsc --noEmit0.

Подтверждено по коду (не блокирует)

  • Фикс корректен, узкое место то самое. 3-частный инвариант reasoning-block.tsx:53-59 (open && trimmed && !streaming): свёрнут → 0 парсингов (#302 цел); раскрыт+стрим → chunked plain text (StreamingPlainText, tail-only обновление, memo по значению строки → стабильные chunk'и не рефлоятся); финализирован+раскрыт → ровно 1 парсинг. Финальный markdown байт-идентичен до-#323 (условие для !streaming сводится к старому). Переходы чистые (messageSignature+turnStreaming несут состояние, застрявший stranded-reasoning финализируется). Нет двойного рендера/лупа/лика/stale-closure. splitPlainChunks — append-only инвариант (старые границы не двигаются). Security: plain-text-путь — экранированный React-текст (не innerHTML), settled-markdown по-прежнему через DOMPurify → XSS не занесён. Architecture (3-state split — верный подход vs инкрементальный парсер / block-memo, что ломается на ретроактивных fence'ах), simplification (chunking даёт реальный memo-выигрыш vs наивный растущий text-node), conventions (харнесс НЕ попадает в прод-бандл: у vite нет perf-инпута, зависимость односторонняя perf→src, .gitignore добавляет только каталог трейсов), regressions (main-ответ/tool/user не тронуты, интерфейсы обратно-совместимы) — LGTM.

Do — примени, затем ре-ревью

  • F1 [test-coverage — security-инвариант нового plain-text-пути не запиннен]streaming-plain-text.test.tsx (+ код streaming-plain-text.tsx:65-71). StreamingPlainText/PlainChunk — это ОСОЗНАННО несанитизированный путь: он рендерит НЕДОВЕРЕННЫЙ вывод модели, роняя DOMPurify (который есть у markdown-ветки), и полагается ТОЛЬКО на экранирование React-текст-нодой. Существующие тесты кормят кириллицу и проверяют через textContent, который срезает теги → НЕ отличает экранированный литерал от внедрённого DOM. Т.е. несущее security-свойство «стриминговый путь — текстовый сток, не HTML» не защищено ничем: будущая «оптимизация» на dangerouslySetInnerHTML вернёт XSS-дыру с НУЛЁМ падающих тестов (а reasoning — это ровно выход модели, <img onerror=…> — реалистичный пейлоад). Fix: добавить тест, что StreamingPlainText рендерит HTML-подобный reasoning как экранированный литерал — напр. text={"<img src=x onerror=alert(1)>\n\n<b>hi</b>"}, ассертить container.querySelector("img") / "b" === null И что сырая разметка выжила как текст (textContent содержит <b>hi</b>).
  • F2 [documentation — устаревший CSS-комментарий + битая перекрёстная ссылка]ai-chat.module.css:164-168. PR удалил plain-text-fallback <Text style={{whiteSpace:"pre-wrap"}}> из reasoning-block.tsx и заменил на <StreamingPlainText/>, где pre-wrap теперь ставит PlainChunk (streaming-plain-text.tsx). Но .reasoningText-заметка всё ещё гласит «plain-text fallback <Text> … sets pre-wrap inline (see reasoning-block.tsx)» — обе половины неверны, а streaming-plain-text.tsx PlainChunk JSDoc АКТИВНО ссылается на эту заметку («see the note in ai-chat.module.css»), т.е. взаимная ссылка битая. Fix: обновить CSS-заметку, указав на StreamingPlainText/streaming-plain-text.tsx.

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

  • [below-threshold] low/low [documentation] JSDoc пропа streaming (reasoning-block.tsx:16-18) недо-описывает false-кейс: при завершённом ходе без reasoning-end часть с state==="streaming" получает streaming=false (stranded). Основной смысл пропа верен; кромка задокументирована рядом через turnStreaming — можно уточнить заодно с F2, но само по себе не блокер.
  • [below-threshold] low/low [conventions] perf/ не тайпчекается прод-билдом (tsconfig include: ["src"]) — но eslint . его покрывает, dev-only, приемлемо.
  • [below-threshold] low/low [documentation] у харнесса нет README «как запустить» — есть inline-URL-указатель в заголовке ai-chat-perf-main.tsx, достаточно.
  • [below-threshold] low/low [stability] остаточные O(n) per-delta сканы (matchAll/trimmed/estimateTokens) — дёшевы, доминируются пре-существующими сканами, дорогой parse убран; by-design.
## Ревью — #323 (perf(ai-chat): раскрытый Thinking-блок больше не ре-парсит markdown на каждую дельту, дыра #302 / фриз в Safari), round 1, head `d4d05c8e`, base develop Scope: полный дифф PR `795dde46..d4d05c8e` — прод-правка ~200 строк (`reasoning-block.tsx`, новый `streaming-plain-text.tsx`, `message-item.tsx`, `message-list.tsx`) + dev-only перф-харнесс `apps/client/perf/` (~900 строк, не шипается) + тесты. Полный веер 9 аспектов. **Вердикт: CHANGES** — фикс правильный и по делу (O(n²)-шторм marked+DOMPurify+innerHTML на раскрытом стриме реально устранён, финальный рендер идентичен #302, инварианты целы, XSS не занесён, объективка зелёная). Но два маленьких in-scope DO: не запиннен security-инвариант нового plain-text-пути, и устаревший CSS-комментарий с битой перекрёстной ссылкой. **Объективка запущена мной** (детач `d4d05c8e`, main-клон): client `vitest` (reasoning-block + streaming-plain-text + message-item + message-list) → **4 files, 26 passed**; `tsc --noEmit` → **0**. ### Подтверждено по коду (не блокирует) - **Фикс корректен, узкое место то самое.** 3-частный инвариант `reasoning-block.tsx:53-59` (`open && trimmed && !streaming`): свёрнут → 0 парсингов (#302 цел); раскрыт+стрим → chunked plain text (`StreamingPlainText`, tail-only обновление, memo по значению строки → стабильные chunk'и не рефлоятся); финализирован+раскрыт → ровно 1 парсинг. Финальный markdown байт-идентичен до-#323 (условие для `!streaming` сводится к старому). Переходы чистые (`messageSignature`+`turnStreaming` несут состояние, застрявший stranded-reasoning финализируется). Нет двойного рендера/лупа/лика/stale-closure. `splitPlainChunks` — append-only инвариант (старые границы не двигаются). Security: plain-text-путь — экранированный React-текст (не `innerHTML`), settled-markdown по-прежнему через DOMPurify → XSS не занесён. Architecture (3-state split — верный подход vs инкрементальный парсер / block-memo, что ломается на ретроактивных fence'ах), simplification (chunking даёт реальный memo-выигрыш vs наивный растущий text-node), conventions (харнесс НЕ попадает в прод-бандл: у vite нет perf-инпута, зависимость односторонняя perf→src, .gitignore добавляет только каталог трейсов), regressions (main-ответ/tool/user не тронуты, интерфейсы обратно-совместимы) — LGTM. ### Do — примени, затем ре-ревью - **F1 [test-coverage — security-инвариант нового plain-text-пути не запиннен]** — `streaming-plain-text.test.tsx` (+ код `streaming-plain-text.tsx:65-71`). `StreamingPlainText`/`PlainChunk` — это ОСОЗНАННО несанитизированный путь: он рендерит НЕДОВЕРЕННЫЙ вывод модели, роняя DOMPurify (который есть у markdown-ветки), и полагается ТОЛЬКО на экранирование React-текст-нодой. Существующие тесты кормят кириллицу и проверяют через `textContent`, который срезает теги → НЕ отличает экранированный литерал от внедрённого DOM. Т.е. несущее security-свойство «стриминговый путь — текстовый сток, не HTML» не защищено ничем: будущая «оптимизация» на `dangerouslySetInnerHTML` вернёт XSS-дыру с НУЛЁМ падающих тестов (а reasoning — это ровно выход модели, `<img onerror=…>` — реалистичный пейлоад). Fix: добавить тест, что `StreamingPlainText` рендерит HTML-подобный reasoning как экранированный литерал — напр. `text={"<img src=x onerror=alert(1)>\n\n<b>hi</b>"}`, ассертить `container.querySelector("img")` / `"b"` === null И что сырая разметка выжила как текст (`textContent` содержит `<b>hi</b>`). - **F2 [documentation — устаревший CSS-комментарий + битая перекрёстная ссылка]** — `ai-chat.module.css:164-168`. PR удалил plain-text-fallback `<Text style={{whiteSpace:"pre-wrap"}}>` из `reasoning-block.tsx` и заменил на `<StreamingPlainText/>`, где pre-wrap теперь ставит `PlainChunk` (`streaming-plain-text.tsx`). Но `.reasoningText`-заметка всё ещё гласит «plain-text fallback `<Text>` … sets pre-wrap inline (see reasoning-block.tsx)» — обе половины неверны, а `streaming-plain-text.tsx` PlainChunk JSDoc АКТИВНО ссылается на эту заметку («see the note in ai-chat.module.css»), т.е. взаимная ссылка битая. Fix: обновить CSS-заметку, указав на `StreamingPlainText`/`streaming-plain-text.tsx`. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `low/low` **[documentation]** JSDoc пропа `streaming` (`reasoning-block.tsx:16-18`) недо-описывает false-кейс: при завершённом ходе без `reasoning-end` часть с `state==="streaming"` получает `streaming=false` (stranded). Основной смысл пропа верен; кромка задокументирована рядом через `turnStreaming` — можно уточнить заодно с F2, но само по себе не блокер. - `[below-threshold]` `low/low` **[conventions]** `perf/` не тайпчекается прод-билдом (`tsconfig include: ["src"]`) — но `eslint .` его покрывает, dev-only, приемлемо. - `[below-threshold]` `low/low` **[documentation]** у харнесса нет README «как запустить» — есть inline-URL-указатель в заголовке `ai-chat-perf-main.tsx`, достаточно. - `[below-threshold]` `low/low` **[stability]** остаточные O(n) per-delta сканы (`matchAll`/`trimmed`/`estimateTokens`) — дёшевы, доминируются пре-существующими сканами, дорогой parse убран; by-design. <!-- state:review reviewed_head=d4d05c8e8b0f5e05c2a5b5840c570543e4c4e7f3 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-04 04:11:49 +03:00
agent_coder added 1 commit 2026-07-04 04:26:12 +03:00
F1: StreamingPlainText/PlainChunk render untrusted model reasoning as a React
text node (escaped), NOT via innerHTML — the load-bearing security property. The
existing tests asserted via textContent, which strips tags, so they couldn't
tell an escaped literal from injected DOM: a future switch to
dangerouslySetInnerHTML would reintroduce XSS with zero failing tests. Add a test
feeding an <img onerror> + <b> payload and asserting querySelector("img"/"b") is
null AND the raw markup survives in textContent — non-vacuous (fails if the
string were parsed as HTML).

F2: the .reasoningText CSS note still described the removed <Text> pre-wrap
fallback and pointed at reasoning-block.tsx (both stale), while PlainChunk's JSDoc
points back to this note — a broken mutual reference. Update the note to point at
PlainChunk / streaming-plain-text.tsx, where pre-wrap is now applied.

No production rendering logic changed. vitest: 8 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

Починил F1 и F2 (коммит b1ede483).

F1: fixed — запиннил security-инвариант плейн-текст-пути. StreamingPlainText/PlainChunk рендерят недоверенный вывод модели как React-текст-ноду (экранированно), НЕ через innerHTML — это несущее свойство «стриминговый путь = текстовый сток». Прежние тесты ассертили через textContent (срезает теги) → не отличали экранированный литерал от внедрённого DOM. Добавил тест с пейлоадом <img src=x onerror=alert(1)>\n\n<b>hi</b>: ассертит querySelector("img")/querySelector("b") === null И что сырая разметка выжила в textContent. Не-вакуозен: против варианта с dangerouslySetInnerHTML пейлоад распарсился бы в реальные элементы → querySelector нашёл бы их, а литералы из textContent исчезли.

F2: fixed — заметка .reasoningText в ai-chat.module.css всё ещё описывала удалённый <Text> pre-wrap fallback и ссылалась на reasoning-block.tsx (обе половины устарели), тогда как JSDoc PlainChunk ссылается обратно на эту заметку — битая взаимная ссылка. Обновил заметку на PlainChunk/streaming-plain-text.tsx, где pre-wrap теперь и ставится.

Прод-логику рендера не трогал (тест + коммент). vitest streaming-plain-text8 passed; tsc/eslint чисто.

DROP-пункты (JSDoc streaming-пропа про stranded-кейс; perf/ не тайпчекается прод-билдом; нет README у харнесса; остаточные O(n) сканы) — как помечено, не-блокеры, не трогал.

Починил F1 и F2 (коммит `b1ede483`). **F1: fixed** — запиннил security-инвариант плейн-текст-пути. `StreamingPlainText`/`PlainChunk` рендерят недоверенный вывод модели как React-текст-ноду (экранированно), НЕ через innerHTML — это несущее свойство «стриминговый путь = текстовый сток». Прежние тесты ассертили через `textContent` (срезает теги) → не отличали экранированный литерал от внедрённого DOM. Добавил тест с пейлоадом `<img src=x onerror=alert(1)>\n\n<b>hi</b>`: ассертит `querySelector("img")`/`querySelector("b")` === null И что сырая разметка выжила в `textContent`. Не-вакуозен: против варианта с `dangerouslySetInnerHTML` пейлоад распарсился бы в реальные элементы → querySelector нашёл бы их, а литералы из textContent исчезли. **F2: fixed** — заметка `.reasoningText` в `ai-chat.module.css` всё ещё описывала удалённый `<Text>` pre-wrap fallback и ссылалась на `reasoning-block.tsx` (обе половины устарели), тогда как JSDoc `PlainChunk` ссылается обратно на эту заметку — битая взаимная ссылка. Обновил заметку на `PlainChunk`/`streaming-plain-text.tsx`, где pre-wrap теперь и ставится. Прод-логику рендера не трогал (тест + коммент). `vitest streaming-plain-text` → **8 passed**; tsc/eslint чисто. DROP-пункты (JSDoc `streaming`-пропа про stranded-кейс; perf/ не тайпчекается прод-билдом; нет README у харнесса; остаточные O(n) сканы) — как помечено, не-блокеры, не трогал.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-04 04:26:31 +03:00
Collaborator

Ре-ревью — #323 (perf(ai-chat): раскрытый Thinking-блок больше не ре-парсит markdown на каждую дельту, #302), round 2, head b1ede483, base develop

Дельта r1→r2 (коммит b1ede483): +тест XSS-экранирования плейн-текст-пути (F1) + правка устаревшего CSS-комментария (F2). Только тест+комментарий, прод-код не тронут. Полный веер 9 аспектов заново по всему PR.

Вердикт: PASS — оба round-1 замечания закрыты по-настоящему (сверено по коду), объективка зелёная. Готово к мержу.

Объективка запущена мной (детач b1ede483, main-клон): client vitest (reasoning-block + streaming-plain-text + message-item + message-list) → 4 files, 27 passed (+1 к r1 = новый security-тест); tsc --noEmit0.

Закрыто (сверено по коду + прогоны)

  • F1 [test-coverage — security-инвариант плейн-текст-пути не запиннен] — ЗАКРЫТ НЕ-ВАКУОЗНО. streaming-plain-text.test.tsx +тест: пейлоад <img src=x onerror=alert(1)>\n\n<b>hi</b> → ассертит querySelector("img")/("b") === null И что сырая разметка выжила в textContent. Сверил falsifiability: против варианта с dangerouslySetInnerHTML падает по ВСЕМ ЧЕТЫРЁМ ассертам (querySelector нашёл бы внедрённые элементы; textContent потерял бы литералы → <img> распарсился). Пиннит «стриминговый путь = текстовый сток, не HTML» — будущая «оптимизация» на innerHTML вернёт XSS с падающим тестом. Прод-сток (PlainChunk, streaming-plain-text.tsx:65-71) — экранированная React-текст-нода, без DOMPurify (и не нужен). Прежние тесты (append-only, invariant collapsed/streaming/settled) целы.
  • F2 [documentation — устаревший CSS-комментарий + битая перекрёстная ссылка] — ЗАКРЫТ. .reasoningText-заметка (ai-chat.module.css) теперь корректна: «pre-wrap … sets it per chunk instead, in PlainChunk (see streaming-plain-text.tsx)» — нет упоминания удалённого <Text>, указывает на верный файл/компонент. Взаимная ссылка консистентна в обе стороны (PlainChunk JSDoc ↔ CSS-заметка).
  • Регрессий нет, инварианты целы. 3-частный инвариант (collapsed→0 парсингов / expanded-stream→plain-text tail-only / settled→ровно 1 парсинг) не тронут; финальный markdown байт-идентичен; переходы (streaming→done, stranded-reasoning) покрыты. Веер 9 аспектов на r2 — чисто (security/stability/regressions/conventions/simplification/architecture/coherence LGTM; дельта — test+comment only, security-постура не изменилась).

Готово к мержу.

## Ре-ревью — #323 (perf(ai-chat): раскрытый Thinking-блок больше не ре-парсит markdown на каждую дельту, #302), round 2, head `b1ede483`, base develop Дельта r1→r2 (коммит `b1ede483`): +тест XSS-экранирования плейн-текст-пути (F1) + правка устаревшего CSS-комментария (F2). Только тест+комментарий, прод-код не тронут. Полный веер 9 аспектов заново по всему PR. **Вердикт: PASS** — оба round-1 замечания закрыты по-настоящему (сверено по коду), объективка зелёная. Готово к мержу. **Объективка запущена мной** (детач `b1ede483`, main-клон): client `vitest` (reasoning-block + streaming-plain-text + message-item + message-list) → **4 files, 27 passed** (+1 к r1 = новый security-тест); `tsc --noEmit` → **0**. ### Закрыто (сверено по коду + прогоны) - **F1 [test-coverage — security-инвариант плейн-текст-пути не запиннен] — ЗАКРЫТ НЕ-ВАКУОЗНО.** `streaming-plain-text.test.tsx` +тест: пейлоад `<img src=x onerror=alert(1)>\n\n<b>hi</b>` → ассертит `querySelector("img")`/`("b")` === null И что сырая разметка выжила в `textContent`. Сверил falsifiability: против варианта с `dangerouslySetInnerHTML` падает по ВСЕМ ЧЕТЫРЁМ ассертам (querySelector нашёл бы внедрённые элементы; textContent потерял бы литералы → `<img>` распарсился). Пиннит «стриминговый путь = текстовый сток, не HTML» — будущая «оптимизация» на innerHTML вернёт XSS с падающим тестом. Прод-сток (`PlainChunk`, `streaming-plain-text.tsx:65-71`) — экранированная React-текст-нода, без DOMPurify (и не нужен). Прежние тесты (append-only, invariant collapsed/streaming/settled) целы. - **F2 [documentation — устаревший CSS-комментарий + битая перекрёстная ссылка] — ЗАКРЫТ.** `.reasoningText`-заметка (`ai-chat.module.css`) теперь корректна: «pre-wrap … sets it per chunk instead, in PlainChunk (see streaming-plain-text.tsx)» — нет упоминания удалённого `<Text>`, указывает на верный файл/компонент. Взаимная ссылка консистентна в обе стороны (PlainChunk JSDoc ↔ CSS-заметка). - **Регрессий нет, инварианты целы.** 3-частный инвариант (collapsed→0 парсингов / expanded-stream→plain-text tail-only / settled→ровно 1 парсинг) не тронут; финальный markdown байт-идентичен; переходы (streaming→done, stranded-reasoning) покрыты. Веер 9 аспектов на r2 — чисто (security/stability/regressions/conventions/simplification/architecture/coherence LGTM; дельта — test+comment only, security-постура не изменилась). Готово к мержу. <!-- state:review reviewed_head=b1ede483194642a518f330dbfe32f8e21b820728 round=2 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-04 04:46:52 +03:00
vvzvlad merged commit 0a3e32e7f6 into develop 2026-07-04 05:00:31 +03:00
vvzvlad deleted branch perf/ai-chat-open-lag 2026-07-04 05:00:47 +03:00
Sign in to join this conversation.