diff --git a/apps/client/src/features/ai-chat/components/ai-chat.module.css b/apps/client/src/features/ai-chat/components/ai-chat.module.css index 71cc0e9d..cd788cdd 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat.module.css +++ b/apps/client/src/features/ai-chat/components/ai-chat.module.css @@ -161,7 +161,11 @@ 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; + /* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the + rendered markdown
it would turn the newlines between block tags + (\n
  • ,

    \n
      ) into visible blank lines/indents on top of the + margins. The plain-text fallback that needs pre-wrap sets it + inline itself (see reasoning-block.tsx). */ } .reasoningText p { diff --git a/apps/client/src/features/ai-chat/components/reasoning-block.tsx b/apps/client/src/features/ai-chat/components/reasoning-block.tsx index 43e88a69..de35229a 100644 --- a/apps/client/src/features/ai-chat/components/reasoning-block.tsx +++ b/apps/client/src/features/ai-chat/components/reasoning-block.tsx @@ -3,6 +3,7 @@ import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core"; import { IconChevronDown } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts"; +import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts"; import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; @@ -33,7 +34,12 @@ export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) { // Authoritative count wins; otherwise estimate live from the streamed text. const count = tokens && tokens > 0 ? tokens : estimateTokens(text); const trimmed = text.trim(); - const html = trimmed ? renderChatMarkdown(trimmed, {}) : ""; + // Collapse the blank-line gaps the model emits between every list item / + // paragraph so the reasoning renders compactly (tight lists, joined + // paragraphs) — see collapseBlankLines. ONLY here, not in the normal answer. + const html = trimmed + ? renderChatMarkdown(collapseBlankLines(trimmed), {}) + : ""; return ( diff --git a/apps/client/src/features/ai-chat/utils/collapse-blank-lines.test.ts b/apps/client/src/features/ai-chat/utils/collapse-blank-lines.test.ts new file mode 100644 index 00000000..d61315dd --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/collapse-blank-lines.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts"; +import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; + +describe("collapseBlankLines", () => { + it("collapses a run of 2+ newlines to a single newline", () => { + expect(collapseBlankLines("a\n\nb")).toBe("a\nb"); + expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb"); + }); + + it("keeps single newlines untouched", () => { + expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc"); + }); + + it("preserves blank lines INSIDE a fenced code block", () => { + const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc"; + // Prose blanks collapse; the blank lines between the ``` fences survive. + expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc"); + }); + + it("handles a tilde fence and preserves its interior blanks", () => { + const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq"; + expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq"); + }); + + it("leaves an unclosed fence's remaining lines verbatim", () => { + const src = "intro\n\n```\nstill\n\nopen"; + expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen"); + }); + + it("is a no-op for text with no blank lines", () => { + expect(collapseBlankLines("just one line")).toBe("just one line"); + }); +}); + +describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => { + it("renders a blank-line-separated list as a TIGHT list (no
    1. )", () => { + const loose = + "Intro paragraph.\n\n- item one\n\n- item two\n\n- item three"; + const html = renderChatMarkdown(collapseBlankLines(loose), {}); + // Tight list: each

    2. holds the text directly, not wrapped in a

      . + expect(html).toContain("

    3. item one
    4. "); + expect(html).not.toContain("
    5. "); + // The list still parses as a list after the paragraph (not a paragraph+
      ). + expect(html).toContain("

        "); + expect(html).toContain("

        Intro paragraph.

        "); + }); + + it("renders an ordered list (1. 2.) as tight after collapsing", () => { + const loose = "Intro.\n\n1. first\n\n2. second"; + const html = renderChatMarkdown(collapseBlankLines(loose), {}); + expect(html).toContain("
          "); + expect(html).toContain("
        1. first
        2. "); + expect(html).not.toContain("
        3. "); + }); + + it("the loose source WOULD render

        4. without collapsing (control)", () => { + const loose = "- a\n\n- b"; + expect(renderChatMarkdown(loose, {})).toContain("

        5. "); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/collapse-blank-lines.ts b/apps/client/src/features/ai-chat/utils/collapse-blank-lines.ts new file mode 100644 index 00000000..17d49902 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/collapse-blank-lines.ts @@ -0,0 +1,56 @@ +// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React +// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts). + +/** + * Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced + * code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant. + * + * Why: reasoning models emit thinking with a blank line (`\n\n`) between every + * list item and paragraph. `marked` turns those into "loose" lists (each `

        6. ` + * wrapped in a `

          `) and separate `

          ` paragraphs, each carrying a vertical + * margin — so the "Thinking" block renders with large, airy gaps. Removing the + * blank-line gaps yields tight lists (no `

        7. `) and joined paragraphs. The + * chat markdown renderer runs with `breaks: true`, so a single `\n` still + * becomes a `
          ` — line breaks inside the reasoning are preserved; only the + * empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a + * normal assistant answer (where paragraph spacing is intentional). + * + * Fenced code is preserved verbatim: a fence opens on a line whose first + * non-space characters are ``` or ~~~ and closes on the next line that starts + * with the same fence character. Blank lines between fences (significant for + * code formatting) are never collapsed. + */ +export function collapseBlankLines(text: string): string { + const lines = text.split("\n"); + const out: string[] = []; + let inFence = false; + let fenceChar = ""; + + for (const line of lines) { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (fenceMatch) { + const ch = fenceMatch[1][0]; + if (!inFence) { + inFence = true; + fenceChar = ch; + } else if (ch === fenceChar) { + inFence = false; + } + out.push(line); + continue; + } + + // Inside a fenced block every line (including blanks) is significant. + if (inFence) { + out.push(line); + continue; + } + + // Outside fences: drop blank lines so a `\n\n+` gap collapses to a single + // `\n` between the surrounding content lines. + if (line.trim() === "") continue; + out.push(line); + } + + return out.join("\n"); +}