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
)", () => { + const loose = + "Intro paragraph.\n\n- item one\n\n- item two\n\n- item three"; + const html = renderChatMarkdown(collapseBlankLines(loose), {}); + // Tight list: each
. + expect(html).toContain("
");
+ // The list still parses as a list after the paragraph (not a paragraph+
).
+ 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(""); + }); + + it("the loose source WOULD render
without collapsing (control)", () => { + const loose = "- a\n\n- b"; + expect(renderChatMarkdown(loose, {})).toContain("
"); + }); +}); 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 `
`) 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 `
`) 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");
+}