[feature][ai-chat] Компактный рендеринг блока «Thinking»: схлопывать пустые строки (\n\n) в reasoning #181

Closed
opened 2026-06-25 00:26:51 +03:00 by Ghost · 0 comments

Контекст

В блоке «Thinking» (раскрывающийся reasoning ассистента) текст рассуждений
отображается с очень большими вертикальными отступами: между пунктами
нумерованного/маркированного списка и между абзацами появляется почти по
целой пустой строке. Из-за этого reasoning занимает много места и выглядит
«рыхло».

Источник проблемы — модель присылает reasoning с двойными переносами строк
(\n\n) между каждым пунктом и абзацем. В Claude Code это же содержимое
показывается компактно.

Идея (из обсуждения): в reasoning можно убирать \n\n — схлопывать пустые
строки перед рендером.

Где это происходит (анализ кода)

Рендер блока: reasoning-block.tsx

// reasoning-block.tsx:36
const html = trimmed ? renderChatMarkdown(trimmed, {}) : "";

renderChatMarkdown (markdown.ts)
прогоняет текст через общий markdownToHtml (marked, см.
marked.utils.ts).

Две независимые причины «рыхлости»:

  1. Loose lists и отдельные абзацы. Когда пункты списка/абзацы в исходном
    markdown разделены пустой строкой (\n\n), marked рендерит «loose list»:
    содержимое каждого <li> оборачивается в <p>, а абзацы становятся
    отдельными <p>. Каждый <p> получает вертикальный margin → большие зазоры.

  2. white-space: pre-wrap на markdown-контейнере. В CSS
    (ai-chat.module.css):

    /* ai-chat.module.css:121 */
    .reasoningText {
        margin-top: 4px;
        font-size: var(--mantine-font-size-xs);
        color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
        white-space: pre-wrap;   /* <-- применяется и к markdown <div> */
    }
    .reasoningText p { margin: 0 0 4px; }
    

    Класс reasoningText навешан и на markdown-<div>dangerouslySetInnerHTML),
    и на текстовый fallback <Text>. Для fallback pre-wrap нужен (там сырой
    текст с переносами), а вот на сгенерированном HTML он лишний: переносы строк
    между блочными тегами (</li>\n<li>, </p>\n<ol> и т.п.) выводятся как
    видимые пустые строки/отступы, добавляя зазоры поверх margin'ов.

Предлагаемое решение

Сделать рендер reasoning компактным, только для блока «Thinking» (ответ
ассистента в message-item.tsx
трогать НЕ нужно — там абзацные отступы уместны):

  1. Схлопывать пустые строки перед рендером. Добавить небольшой util,
    например collapseBlankLines(text), который заменяет последовательности из
    2+ переносов на один (/\n{2,}/g → "\n"), и применять его в
    reasoning-block.tsx к trimmed до вызова renderChatMarkdown.

    Так loose lists превращаются в tight lists (без <p> внутри <li>), а абзацы
    склеиваются. Важно: marked сконфигурирован с breaks: true, поэтому
    одиночный \n всё равно даёт <br> — переносы строк сохранятся, исчезнут
    только «пустые» зазоры между блоками. Это ровно тот компактный вид, что нужен.

  2. Сузить white-space: pre-wrap так, чтобы он применялся только к
    текстовому fallback, а не к markdown-<div>. Например, ввести отдельный
    класс/модификатор для fallback или убрать pre-wrap из .reasoningText и
    оставить его инлайном только на <Text> (там он уже задан:
    style={{ whiteSpace: "pre-wrap" }}).

Edge cases / на что обратить внимание

  • Fenced code blocks (```). Внутри блоков кода пустые строки
    значимы — их схлопывать НЕЛЬЗЯ, иначе ломается форматирование кода.
    collapseBlankLines должен пропускать содержимое fenced-блоков (в reasoning
    код редок, но возможен).
  • Список сразу после абзаца. После схлопывания абзац и следующий список
    окажутся на соседних строках без пустой строки между ними. Для маркеров -/*
    и нумерации, начинающейся с 1., marked/GFM это обычно корректно
    распознаёт как список, но стоит проверить тестом.
  • Не задеть ответ ассистента. Нормализация должна жить в reasoning-пути
    reasoning-block.tsx или как опция renderChatMarkdown), а не в общем
    рендере, иначе схлопнутся абзацы и в обычных ответах.

Критерии приёмки

  • В раскрытом блоке «Thinking» нет «пустых строк» между пунктами списка и между
    абзацами; reasoning выглядит компактно (как в Claude Code на скриншоте).
  • Списки рендерятся как tight (без <p> внутри <li>), переносы строк внутри
    сохраняются.
  • Блоки кода в reasoning (если есть) не искажаются.
  • Рендер обычных ответов ассистента не изменился.

Тесты

  • Unit на collapseBlankLines: \n\n+\n; пустые строки внутри fenced-кода
    сохраняются.
  • Тест на reasoning-block: markdown со списком, разделённым пустыми строками,
    рендерится как tight list (нет <li><p>), без видимых пустых строк.
## Контекст В блоке «Thinking» (раскрывающийся reasoning ассистента) текст рассуждений отображается с очень большими вертикальными отступами: между пунктами нумерованного/маркированного списка и между абзацами появляется почти по целой пустой строке. Из-за этого reasoning занимает много места и выглядит «рыхло». Источник проблемы — модель присылает reasoning с двойными переносами строк (`\n\n`) между каждым пунктом и абзацем. В Claude Code это же содержимое показывается компактно. Идея (из обсуждения): **в reasoning можно убирать `\n\n`** — схлопывать пустые строки перед рендером. ## Где это происходит (анализ кода) Рендер блока: [reasoning-block.tsx](apps/client/src/features/ai-chat/components/reasoning-block.tsx) ```tsx // reasoning-block.tsx:36 const html = trimmed ? renderChatMarkdown(trimmed, {}) : ""; ``` `renderChatMarkdown` ([markdown.ts](apps/client/src/features/ai-chat/utils/markdown.ts)) прогоняет текст через общий `markdownToHtml` (`marked`, см. [marked.utils.ts](packages/editor-ext/src/lib/markdown/utils/marked.utils.ts)). Две независимые причины «рыхлости»: 1. **Loose lists и отдельные абзацы.** Когда пункты списка/абзацы в исходном markdown разделены пустой строкой (`\n\n`), `marked` рендерит «loose list»: содержимое каждого `<li>` оборачивается в `<p>`, а абзацы становятся отдельными `<p>`. Каждый `<p>` получает вертикальный margin → большие зазоры. 2. **`white-space: pre-wrap` на markdown-контейнере.** В CSS ([ai-chat.module.css](apps/client/src/features/ai-chat/components/ai-chat.module.css)): ```css /* ai-chat.module.css:121 */ .reasoningText { margin-top: 4px; font-size: var(--mantine-font-size-xs); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); white-space: pre-wrap; /* <-- применяется и к markdown <div> */ } .reasoningText p { margin: 0 0 4px; } ``` Класс `reasoningText` навешан и на markdown-`<div>` (с `dangerouslySetInnerHTML`), и на текстовый fallback `<Text>`. Для fallback `pre-wrap` нужен (там сырой текст с переносами), а вот на сгенерированном HTML он лишний: переносы строк между блочными тегами (`</li>\n<li>`, `</p>\n<ol>` и т.п.) выводятся как видимые пустые строки/отступы, добавляя зазоры поверх margin'ов. ## Предлагаемое решение Сделать рендер reasoning компактным, **только для блока «Thinking»** (ответ ассистента в [message-item.tsx](apps/client/src/features/ai-chat/components/message-item.tsx) трогать НЕ нужно — там абзацные отступы уместны): 1. **Схлопывать пустые строки перед рендером.** Добавить небольшой util, например `collapseBlankLines(text)`, который заменяет последовательности из 2+ переносов на один (`/\n{2,}/g → "\n"`), и применять его в `reasoning-block.tsx` к `trimmed` до вызова `renderChatMarkdown`. Так loose lists превращаются в tight lists (без `<p>` внутри `<li>`), а абзацы склеиваются. Важно: `marked` сконфигурирован с `breaks: true`, поэтому одиночный `\n` всё равно даёт `<br>` — переносы строк сохранятся, исчезнут только «пустые» зазоры между блоками. Это ровно тот компактный вид, что нужен. 2. **Сузить `white-space: pre-wrap`** так, чтобы он применялся только к текстовому fallback, а не к markdown-`<div>`. Например, ввести отдельный класс/модификатор для fallback или убрать `pre-wrap` из `.reasoningText` и оставить его инлайном только на `<Text>` (там он уже задан: `style={{ whiteSpace: "pre-wrap" }}`). ## Edge cases / на что обратить внимание - **Fenced code blocks (```` ``` ````).** Внутри блоков кода пустые строки значимы — их схлопывать НЕЛЬЗЯ, иначе ломается форматирование кода. `collapseBlankLines` должен пропускать содержимое fenced-блоков (в reasoning код редок, но возможен). - **Список сразу после абзаца.** После схлопывания абзац и следующий список окажутся на соседних строках без пустой строки между ними. Для маркеров `-`/`*` и нумерации, начинающейся с `1.`, `marked`/GFM это обычно корректно распознаёт как список, но стоит проверить тестом. - **Не задеть ответ ассистента.** Нормализация должна жить в reasoning-пути (в `reasoning-block.tsx` или как опция `renderChatMarkdown`), а не в общем рендере, иначе схлопнутся абзацы и в обычных ответах. ## Критерии приёмки - В раскрытом блоке «Thinking» нет «пустых строк» между пунктами списка и между абзацами; reasoning выглядит компактно (как в Claude Code на скриншоте). - Списки рендерятся как tight (без `<p>` внутри `<li>`), переносы строк внутри сохраняются. - Блоки кода в reasoning (если есть) не искажаются. - Рендер обычных ответов ассистента не изменился. ## Тесты - Unit на `collapseBlankLines`: `\n\n+` → `\n`; пустые строки внутри fenced-кода сохраняются. - Тест на `reasoning-block`: markdown со списком, разделённым пустыми строками, рендерится как tight list (нет `<li><p>`), без видимых пустых строк.
Ghost added the feature label 2026-06-25 00:26:51 +03:00
Ghost closed this issue 2026-06-25 12:49:15 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#181